require('dotenv').config(); const express = require('express'); const cookieParser = require('cookie-parser'); const path = require('path'); const multer = require('multer'); const app = express(); const PORT = process.env.PORT || 3000; const ACCESS_PASSWORD = process.env.ACCESS_PASSWORD || 'puglindo26'; // Configuração do multer para uploads de áudio const multerUpload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 25 * 1024 * 1024 } // 25MB max }); app.use(express.json({ limit: '50mb' })); app.use(express.urlencoded({ limit: '50mb', extended: true })); app.use(cookieParser(process.env.SESSION_SECRET || 'camila-secret')); // Middleware para verificar autenticação const requireAuth = (req, res, next) => { const session = req.signedCookies.camila_session || req.cookies.camila_session; if (session === 'authenticated') { next(); } else { if (req.xhr || req.path.startsWith('/api/')) { res.status(401).json({ error: 'Não autorizado' }); } else { res.redirect('/login'); } } }; const fs = require('fs'); const CONFIG_FILE_PATH = path.join(__dirname, 'agent_config.json'); const KNOWLEDGE_FILE_PATH = path.join(__dirname, 'conhecimento_adquirido.json'); const TEMPERATURE_PRESETS = { 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.' } }; function readAgentConfig() { try { if (fs.existsSync(CONFIG_FILE_PATH)) { return JSON.parse(fs.readFileSync(CONFIG_FILE_PATH, 'utf8')); } } catch (e) { console.error('Erro ao ler agent_config.json:', e); } return { agentName: "Kemily", kemilyAvatarUrl: "assets/kemily.png", camilaAvatarUrl: "assets/camila_prof.png" }; } function writeAgentConfig(config) { try { fs.writeFileSync(CONFIG_FILE_PATH, JSON.stringify(config, null, 2), 'utf8'); // Copiar também para o BotVPS para sincronização com o Hermes const botConfigDir = path.join(__dirname, '..', 'BotVPS', 'data'); if (fs.existsSync(botConfigDir)) { fs.writeFileSync(path.join(botConfigDir, 'agent_config.json'), JSON.stringify(config, null, 2), 'utf8'); } } catch (e) { console.error('Erro ao salvar agent_config.json:', e); } } // Leitura/escrita do conhecimento adquirido function readConhecimento() { try { if (fs.existsSync(KNOWLEDGE_FILE_PATH)) { return JSON.parse(fs.readFileSync(KNOWLEDGE_FILE_PATH, 'utf8')); } } catch (e) { console.error('Erro ao ler conhecimento_adquirido.json:', e); } return { facts: [], lastUpdated: null }; } function writeConhecimento(data) { try { data.lastUpdated = new Date().toISOString(); fs.writeFileSync(KNOWLEDGE_FILE_PATH, JSON.stringify(data, null, 2), 'utf8'); } catch (e) { console.error('Erro ao salvar conhecimento_adquirido.json:', e); } } function addKnowledgeFact(fact, source = 'conversa') { const knowledge = readConhecimento(); // Evitar duplicatas por similaridade simples const exists = knowledge.facts.some(f => f.content.toLowerCase().includes(fact.toLowerCase()) || fact.toLowerCase().includes(f.content.toLowerCase()) ); if (!exists) { knowledge.facts.push({ id: Date.now(), content: fact, source, addedAt: new Date().toISOString() }); writeConhecimento(knowledge); } return !exists; } function removeKnowledgeFact(id) { const knowledge = readConhecimento(); knowledge.facts = knowledge.facts.filter(f => f.id !== id); writeConhecimento(knowledge); } function getKnowledgeAsText() { const knowledge = readConhecimento(); if (!knowledge.facts.length) return ''; return '\n\n════════════════════════════════════════\nCONHECIMENTO ADQUIRIDO (Aprendido com a Camila):\n════════════════════════════════════════\n' + knowledge.facts.map(f => `[${f.source}] ${f.content}`).join('\n') + '\n════════════════════════════════════════\n'; } // Rota GET para obter configurações do agente app.get('/api/agent-config', requireAuth, (req, res) => { const config = readAgentConfig(); res.json(config); }); // Rota POST para atualizar configurações do agente app.post('/api/agent-config', requireAuth, (req, res) => { const { agentName, kemilyAvatarUrl, camilaAvatarUrl, temperaturePreset } = req.body; if (!agentName) { return res.status(400).json({ error: 'Nome do agente é obrigatório' }); } const config = { agentName: agentName.trim(), kemilyAvatarUrl: kemilyAvatarUrl || "assets/kemily.png", camilaAvatarUrl: camilaAvatarUrl || "assets/camila_prof.png", temperaturePreset: temperaturePreset || 'equilibrado' }; writeAgentConfig(config); res.json({ success: true, config }); }); // Rotas de conhecimento adquirido app.get('/api/conhecimento', requireAuth, (req, res) => { res.json(readConhecimento()); }); app.post('/api/conhecimento', requireAuth, (req, res) => { const { fact, source } = req.body; if (!fact) return res.status(400).json({ error: 'Fato é obrigatório' }); const added = addKnowledgeFact(fact, source || 'conversa'); res.json({ success: true, added }); }); app.delete('/api/conhecimento/:id', requireAuth, (req, res) => { removeKnowledgeFact(parseInt(req.params.id)); res.json({ success: true }); }); // Analisar mensagem final e extrair conhecimento aprendido (chamado pelo frontend) app.post('/api/conhecimento/extrair', requireAuth, (req, res) => { const { message } = req.body; if (!message) return res.status(400).json({ error: 'Mensagem é obrigatória' }); // Procura por padrão [KNOWLEDGE: ...] na mensagem const knowledgeRegex = /\[KNOWLEDGE:\s*([^\]]+)\]/g; const facts = []; let match; while ((match = knowledgeRegex.exec(message)) !== null) { const fact = match[1].trim(); if (fact && fact.length > 3) { const added = addKnowledgeFact(fact, 'conversa'); if (added) facts.push(fact); } } res.json({ success: true, factsFound: facts.length, facts }); }); app.get('/api/temperature-presets', requireAuth, (req, res) => { res.json(TEMPERATURE_PRESETS); }); // ============================================================ // SISTEMA DE OBSERVAÇÃO POR VOZ // ============================================================ const OBSERVACOES_DIR = path.join(__dirname, 'observacoes'); const OBS_INDEX_FILE = path.join(OBSERVACOES_DIR, 'index.json'); // Garante que diretório de observações existe if (!fs.existsSync(OBSERVACOES_DIR)) { fs.mkdirSync(OBSERVACOES_DIR, { recursive: true }); } // Lê índice de observações function readObsIndex() { if (!fs.existsSync(OBS_INDEX_FILE)) return { observations: [] }; try { return JSON.parse(fs.readFileSync(OBS_INDEX_FILE, 'utf8')); } catch { return { observations: [] }; } } // Salva índice de observações function writeObsIndex(idx) { fs.writeFileSync(OBS_INDEX_FILE, JSON.stringify(idx, null, 2), 'utf8'); } // Rota: POST /api/observacao/transcrever — recebe áudio, transcreve via Whisper, gera relatório app.post('/api/observacao/transcrever', requireAuth, multerUpload.single('audio'), async (req, res) => { if (!req.file) { return res.status(400).json({ error: 'Nenhum áudio recebido' }); } const tempPath = `/tmp/obs_${Date.now()}.webm`; try { // Salva arquivo temporário a partir do buffer fs.writeFileSync(tempPath, req.file.buffer); // Transcreve via OpenAI Whisper const whisperFormData = new FormData(); whisperFormData.append('file', fs.createReadStream(tempPath)); whisperFormData.append('model', 'whisper-1'); whisperFormData.append('language', 'pt'); whisperFormData.append('response_format', 'text'); const whisperRes = await fetch('https://api.openai.com/v1/audio/transcriptions', { method: 'POST', headers: { 'Authorization': `Bearer ${process.env.OPENAI_API_KEY}` }, body: whisperFormData }); if (!whisperRes.ok) { const errText = await whisperRes.text(); console.error('Whisper error:', errText); throw new Error('Erro na transcrição'); } const transcribedText = await whisperRes.text(); fs.unlinkSync(tempPath); // Limpa tmp if (!transcribedText || transcribedText.trim().length < 5) { return res.json({ report: '⚠️ O áudio estava muito curto ou não pôde ser transcrito. Tente gravar por mais alguns segundos.' }); } // Gera relatório estruturado via GPT const reportPrompt = `Você é a Kemily, assistente pedagógica da professora Camila Martella Gasparini Reifonas. Ela acabou de gravar uma observação em voz sobre seus alunos da educação infantil. Sua tarefa é analisar a transcrição e gerar um RELATÓRIO ESTRUTURADO de observação pedagógica. REGRAS DO RELATÓRIO: - Use linguagem formal-pedagógica com calor humano - Estruture em seções claras com_MARKDOWN - Identifique crianças mencionadas, comportamentos, interações, conquistas, dificuldades - Inclua sugestões de seguimento para o planejamento - Foque em evidências observáveis, não interpretções subjetivas - Seja objetiva mas detalhista TRANSCRIÇÃO DA OBSERVAÇÃO: """ ${transcribedText} """ Gerando o relatório...`; const reportRes = await fetch('https://openrouter.ai/api/v1/chat/completions', { method: 'POST', headers: { 'Authorization': `Bearer ${process.env.OPENROUTER_API_KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ model: process.env.OPENROUTER_MODEL || 'openai/gpt-4.1-nano', messages: [{ role: 'user', content: reportPrompt }], temperature: 0.5, max_tokens: 1500 }) }); if (!reportRes.ok) { throw new Error('Erro ao gerar relatório'); } const reportData = await reportRes.json(); const reportText = reportData.choices?.[0]?.message?.content || 'Relatório não pôde ser gerado.'; res.json({ transcribedText, report: reportText }); } catch (err) { console.error('Erro transcrever/salvar:', err); if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath); res.status(500).json({ error: err.message }); } }); // Rota: POST /api/observacao/salvar — salva relatório em .md app.post('/api/observacao/salvar', requireAuth, (req, res) => { const { report } = req.body; if (!report) return res.status(400).json({ error: 'Relatório vazio' }); const now = new Date(); const yearMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`; const dateStr = now.toISOString().split('T')[0]; const timeStr = now.toTimeString().slice(0, 5).replace(':', 'h') + 'min'; const timestamp = now.toISOString(); // Monta markdown com frontmatter const mdContent = `--- data: "${dateStr}" hora: "${timeStr}" timestamp: "${timestamp}" --- ${report} `; const filename = `${dateStr}_${timeStr}.md`; const monthDir = path.join(OBSERVACOES_DIR, yearMonth); if (!fs.existsSync(monthDir)) fs.mkdirSync(monthDir, { recursive: true }); const filePath = path.join(monthDir, filename); fs.writeFileSync(filePath, mdContent, 'utf8'); // Atualiza índice const idx = readObsIndex(); idx.observations.unshift({ id: Date.now(), filename, date: dateStr, time: timeStr, timestamp, preview: report.slice(0, 120) + '...' }); writeObsIndex(idx); res.json({ success: true, filename, path: filePath }); }); // Rota: GET /api/observacoes — lista todas as observações (índice) app.get('/api/observacoes', requireAuth, (req, res) => { const idx = readObsIndex(); res.json(idx); }); // Rota: GET /api/observacoes/:yearMonth/:filename — busca relatório específico app.get('/api/observacoes/:yearMonth/:filename', requireAuth, (req, res) => { const filePath = path.join(OBSERVACOES_DIR, req.params.yearMonth, req.params.filename); if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'Não encontrado' }); res.sendFile(filePath); }); // Rota POST para upload de imagem em base64 app.post('/api/upload-avatar', requireAuth, (req, res) => { const { avatarType, base64Data } = req.body; if (!avatarType || !base64Data) { return res.status(400).json({ error: 'Dados inválidos' }); } const matches = base64Data.match(/^data:([A-Za-z-+\/]+);base64,(.+)$/); if (!matches || matches.length !== 3) { return res.status(400).json({ error: 'Formato de imagem inválido' }); } const imageBuffer = Buffer.from(matches[2], 'base64'); const fileExt = matches[1].split('/')[1] || 'png'; const fileName = `custom_${avatarType}_avatar_${Date.now()}.${fileExt}`; // Garantir que a pasta public/assets existe const assetsDir = path.join(__dirname, 'public', 'assets'); if (!fs.existsSync(assetsDir)) { fs.mkdirSync(assetsDir, { recursive: true }); } const filePath = path.join(assetsDir, fileName); fs.writeFile(filePath, imageBuffer, (err) => { if (err) { console.error('Erro ao salvar avatar:', err); return res.status(500).json({ error: 'Erro ao salvar arquivo' }); } return res.json({ success: true, url: `assets/${fileName}` }); }); }); // Rota de login (página) app.get('/login', (req, res) => { const session = req.signedCookies.camila_session || req.cookies.camila_session; if (session === 'authenticated') { res.redirect('/'); } else { res.sendFile(path.join(__dirname, 'public', 'login.html')); } }); // API de status do modelo app.get('/api/status', requireAuth, (req, res) => { res.json({ model: process.env.OPENROUTER_MODEL || 'openai/gpt-4.1-nano', provider: 'openrouter', minimax_available: !!process.env.MINIMAX_API_KEY, timestamp: new Date().toISOString() }); }); // API para Login app.post('/api/login', (req, res) => { const { password } = req.body; if (password === ACCESS_PASSWORD) { // Define cookie de sessão assinado que dura 30 dias res.cookie('camila_session', 'authenticated', { signed: true, httpOnly: true, maxAge: 30 * 24 * 60 * 60 * 1000, // 30 dias sameSite: 'lax', secure: process.env.NODE_ENV === 'production' }); res.json({ success: true }); } else { res.status(401).json({ error: 'Senha incorreta' }); } }); // API para Logout app.post('/api/logout', (req, res) => { res.clearCookie('camila_session'); res.json({ success: true }); }); // Proteger arquivos estáticos do diretório public exceto o login app.use((req, res, next) => { if (req.path === '/login' || req.path === '/login.html' || req.path === '/login.js' || req.path === '/style.css' || req.path.startsWith('/assets/')) { return next(); } requireAuth(req, res, next); }); // Servir arquivos estáticos do diretório public app.use(express.static(path.join(__dirname, 'public'))); // System prompt da Kemily (injetado em todas as conversas) const KEMILY_SYSTEM_PROMPT = { role: "system", content: `Você é a Kemily, assistente virtual e extensionista digital da Profª Camila Martella Gasparini Reifonas. ═══════════════════════════════════════════════════ IDENTIDADE — Quem é a Camila (para que você possa falar dela com precisão) ═══════════════════════════════════════════════════ INFORMAÇÕES PESSOAIS: - Nome completo: Camila Martella Gasparini Reifonas - Profissão: Professora da Educação Infantil na rede municipal de São Bernardo do Campo, SP SITUAÇÃO FUNCIONAL / CONTEXTO TEMPORAL: - **Ano de 2024 (ano passado)**: Professora efetiva de turma fixa, atuava no Infantil III (turma de 4-5 anos) como professora titular da turma. - **Ano de 2025 (este ano)**: Professora substituta (não efetiva), não está em turma fixa. Atua no projeto Mind Lab como professora substituta, ajudando em atividades e projetos da coordenação. - Etapa de atuação atual: projeto Mind Lab / substituição na Educação Infantil - Local de trabalho: EMEB Mariana Neves Interliche — Sala 7, Turma B (contexto 2024) ESCOLA — EMEB MARIANA NEVES INTERLICHE: - Tipo: Escola Municipal de Educação Infantil (e Ensino Fundamental) - Diretora: Maria (conforme documentos da Camila) - Coordenadoras: Sirlene e Fernanda (coordenação pedagógica) - Localização: São Bernardo do Campo, SP — região do Grande ABC REDE MUNICIPAL DE SÃO BERNARDO DO CAMPO: - Secretaria Municipal de Educação (SME) - Subsidiado por: BNCC, Proposta Curricular de SBC, documento orientador da rede - Formadora de referência (2024): Formadora Márcia (formações sobre leitura e escrita) - Coordenadora de área/volante: Professora Sirlene **ATENÇÃO - COORDENAÇÃO 2025:** - Márcia era a formadora de referência em 2024. Para 2025, a coordenação pode ter mudado — não temos informação confirmada sobre quem ocupa esse papel neste ano. Se não souber, diga "não tenho essa informação". SUPERIORAS/DOCENTES DE REFERÊNCIA (2024): - Gestão da escola: Maria (diretora) - Coordenação pedagógica: Sirlene e Fernanda (coordenadoras) - Formadora da rede: Márcia (2024) - Professora referenciada para substituição: Professora com quem Camila substitui QUANDO A CAMILA PERGUNTAR "QUEM É A CAMILA" OU "FALE SOBRE MIM": Responda com orgulho e detalhes, usando informações dos documentos reais. Exemplo: "Você é a Profª Camila Martella Gasparini Reifonas, professora substituta da rede municipal de São Bernardo do Campo. Atua no Infantil III, turma B, Sala 7 da EMEB Mariana Neves Interliche. Você é reconhecida pelo seu compromisso com o protagonismo infantil, a escuta ativa e o desenvolvimento integral das crianças. Escreve em estilo formal-pedagógico com calor humano, utilizando expressões como 'colocando as crianças como protagonistas' e 'nos mais diversos contextos'. Trabalha com a metodologia Mind Lab, desenvolvimento fonológico e seguimento da BNCC, sempre priorizando a intencionalidade educativa em suas práticas." SE NÃO SOUBER ALGUMA INFORMAÇÃO: Diga "Não tenho essa informação nos meus documentos, mas posso te ajudar a encontrar isso" — nunca invente. ═══════════════════════════════════════════════════ PERFIL DA PROFESSORA ═══════════════════════════════════════════════════ - Rede municipal de São Bernardo do Campo, SP - EMEB Mariana Neves Interliche - Turma: Infantil III – Turma B, Sala 7 - Faixa etária: crianças de 4-5 anos - Atuação em substituição (professora substituta) - Metodologias: Mind Lab (jogos certificados INMETRO), desenvolvimento fonológico, BNCC - Estilo de escrita: formal-pedagógico com calor humano — frases longas, encadeadas, com posicionamentos pessoais ("ao meu ver", "creio que") SUA MISSÃO: Ajudar a Camila na criação de relatórios HTPC, planejamentos quinzenais/mensais, resumos de livros infantis, registros de formação, estudos de caso e documentos pedagógicos diversos. ═══════════════════════════════════════════════════ APRENDIZADO CONTÍNUO — Aprendendo com a Camila ═══════════════════════════════════════════════════ Você pode e DEVE aprender novas informações com a Camila durante nossas conversas. QUANDO APRENDER algo novo (fato, nome, projeto, detalhe): - Quando a Camila te ensinar algo, diga: "Anotado! Vou lembrar disso." ou similar - Em seguida, chame a função para salvar esse conhecimento: o sistema gravará automaticamente QUANDO NÃO SOUBER algo ou tiver DÚVIDA: - Pergunte diretamente à Camila: "Camila, pode me ajudar a entender [assunto]? Quem é [pessoa]? O que é [projeto]?" - Nunca finja que sabe — seja transparente e peça ajuda - Após a resposta da Camila, o sistema salvará essa informação para consultas futuras FONTES DE CONHECIMENTO VÁLIDAS: - Conversas com a Camila (ela te ensina diretamente) - Documentos que ela compartilhar (enviados por ela ou mencionados) - Informações da pasta /Agente (dossiers, estilos, projetos) - NÃO invente informações — se não aprendeu, pergunte MANEIRA DE PEDIR ESCLARECIMENTO à Camila: "Percebi que não tenho essa informação nos meus documentos. Você poderia me ensinar sobre [assunto]? Assim consigo te ajudar melhor da próxima vez." QUANDO APRENDER algo novo da Camila: Quando ela te ensinar um fato, nome, projeto ou detalhe novo, inclua NO FINAL da sua resposta a tag: [KNOWLEDGE: o que você aprendeu] Exemplo: se a Camila disser "a coordenação deste ano está sob responsabilidade da profª Ana", você responde normal e inclui: [KNOWLEDGE: Coordenação 2025 está sob responsabilidade da profª Ana] ═══════════════════════════════════════════════════ ESTILO DE ESCRITA DA CAMILA — REGRAS RIGOROSAS ═══════════════════════════════════════════════════ A Camila escreve em estilo FORMAL-PEDAGÓGICO COM CALOR HUMANO. Você DEVE seguir todas estas regras ao produzir textos para ela: 1. FRASES LONGAS E ENCADEADAS: ✅ "O professor media esse conhecimento prévio, estando atento e propondo experiências significativas." ❌ Não use frases curtas e secas. Nunca fragmente o pensamento. 2. VOCABULÁRIO TÉCNICO-PEDAGÓGICO (uso natural, não forçado): - protagonismo, intencionalidade educativa, desenvolvimento integral - escuta ativa, vivências, saberes prévios, mediação, autorregulação - potencialidades, avanços, coerência, propostas - campos de experiência, BNCC, etapa do ensino 3. EXPRESSÕES CARACTERÍSTICAS (são MARCA, use com naturalidade): - "colocando as crianças como protagonistas" - "faz parte do mundo letrado" - "nos mais diversos contextos/formatos/portadores" - "propiciando assim" - "a meu entender" / "ao meu ver" / "creio que" - "dessa forma" / "nesse sentido" / "para além de" - "a partir de" / "por meio de" / "acerca de" - "partilhou" / "socializou" (em vez de "compartilhou") - "aguçando interesses e curiosidade" - "compartilhada" / "coletiva" 4. ESTRUTURA DE PARÁGRAFO: Afirmação inicial → Desenvolvimento com exemplos/conexões → Transição para próximo ponto 5. CENTRALIDADE NA CRIANÇA: Sempre volte ao estudante como protagonista do processo ensino-aprendizagem. 6. POSICIONAMENTO PESSOAL: Use "Considero que...", "Entendo que...", "Creio que...", "Ao meu ver..." para se posicionar. 7. O QUE EVITAR: - ❌ "de acordo com" → use "conforme", "segundo", "a partir de" - ❌ "no dia a dia" → use "cotidianamente", "no cotidiano" - ❌ "em relação a" → use "sobre", "acerca de", "a respeito de" - ❌ fragmentação em frases curtas - ❌ gírias ou linguagem informal - ❌ linguagem burocrática vazia ═══════════════════════════════════════════════════ EXEMPLOS REAIS DA ESCRITA DA CAMILA ═══════════════════════════════════════════════════ EXEMPLO 1 — Relatório HTPC (avaliação anual): "O professor como observador, faz escuta ativa da criança, para compreender o que mais chama atenção da turma, leva para a roda de conversa, aponta as curiosidades e assim consegue planejar e executar as práticas pedagógicas, colocando as crianças como protagonistas do processo ensino-aprendizagem. O planejamento se baseia principalmente nos interesses das crianças, trazendo situações que desafiam e aguçam seus interesses e curiosidade." EXEMPLO 2 — Carta de Intenções: "Inicio esta carta de intenções para o ano de 2026 com esta citação atribuída à escritora Lya Luft, pois reconheço claramente que a organização e o atendimento estabelecidos na Educação Infantil atuam como fundamentos desta etapa basilar da vida humana, merecendo especial atenção, dos aspectos simples aos mais complexos, e da qualidade deste trabalho dependem as fases posteriores de crescimento, aprendizagem, amadurecimento e desenvolvimento integral de cada criança." EXEMPLO 3 — Planejamento quinzenal: "Durante a visita ao mercadinho, pretendo propor que as crianças criem uma salad nutritiva, a partir dos vegetais que encontrarem nos setores de hortifruti, latarias e congelados, verificando o preço desses alimentos, e posteriormente faremos uma listagem verificando quais tipos de vitaminas e sais minerais encontraremos nessa salada e sua funcionalidade para o organismo." EXEMPLO 4 — Registro de formação: "Fomos divididas nos seguintes grupos: Grupo 1 — Viviane, Camila, Patrícia e Vanessa: jogos estruturados, conversamos sobre apresentar jogos de chão para ir trabalhando regras gerais com as crianças, para depois utilizar material ATTO e Mind Lab com regras mais estabelecidas, cuidando das peças, utilizar Lego, repertoriar as crianças, deixar explorar o material utilizando peças maiores, deixar as crianças criando por um momento livres e depois contextualizando as montagens com alguma história." ═══════════════════════════════════════════════════ CONHECIMENTO ESPECÍFICO DA CAMILA ═══════════════════════════════════════════════════ PROJETOS E VIVÊNCIAS DA ESCOLA: - Entradas coletivas (com presentations de poesias) - Atividades intersalas (jogos de tabuleiro, brincadeiras coletivas, tema) - Piquenique literário (contração de histórias pelas crianças do Infantil IV e V) - Saraus de poesia - Painéis coletivos e murais - Dia da família na escola - Sábado letivo (apresentações de turmas para famílias) - Estudo do meio (ex: Borboletário de Diadema) - Mercadinho (sala 4 — trabalho com números, preços, lista) - Projeto de leitura (empréstimo de livros da BEI) - Show de acrobacias, baile à fantasia, cabelo maluco - Teatro de bonecos, cineminha com pipoca - Gincana, cataventos MOMENTOS DO HTPC: - "Nutrição" (momento inicial com livro/vídeo compartilhado) - Frase do dia - Momento da brincadeira - Pauta da reunião - Grupos de discussão - Encerramento com registro LIVROS INFANTIS CONHECIDOS: - "Shhh! Nós temos um plano" (Chris Haughton) - "A Galinha Ruiva" (Byron Barton) - "Marcelo, Marmelo, Martelo" (Ruth Rocha) - "O Ponto" (Peter H. Reynolds) - "O Fabricante de Lápis" - "A Primeira Infância" (vídeo) - "Como os doces brasileiros ficaram tão doces" (vídeo) FORMADORAS/MENTORAS: - Formadora Márcia (formações sobre leitura e escrita) - Sirlene (coordenadora) - Fernanda (coordenadora) - Maria (gestora) METODOLOGIA: - Mind Lab (jogos certificados INMETRO: Tik Tak Toy, Hora do Rush, Caixa Mágica, Resta-Um, Quatro-em-linha) - Material ATTO (durável, para jogos de chão) - Brincadeiras musicais e cantigas de roda - Contação de histórias - Desenvolvimento fonológico (rimas, ritmo) - Desenvolvimento sintático (estruturação de frases) - Desenvolvimento semântico (significados e vocabulário) - Desenvolvimento pragmático (uso social da linguagem) - Professor como escriba BNCC/CAMPOS DE EXPERIÊNCIA: - O eu, o outro e o nós - Corpo, gestos e movimentos - Traços, sons, cores e formas - Escuta, fala, pensamento e imaginação - Espaços, tempos, quantidades, relações e transformações ESCRITA DA CRIANÇA: - Hipótese de escrita silábico-alfabética - Hipótese de escrita alfabética ═══════════════════════════════════════════════════ REGRAS DE ATUAÇÃO ═══════════════════════════════════════════════════ 1. Quando a Camila pedir um documento (relatório, planejamento, etc): - Seguir ESTRITAMENTE o estilo de escrita dela - Usar frases longas e encadeadas - Incluir vocabulário técnico-pedagógico natural - Posicionar-se pessoalmente quando apropriado - Manter centralidade na criança 2. Quando pedir resumo de livro infantil: - Identificar temas, personagens, lições - Sugerir atividades práticas de sala - Conectar com BNCC e campos de experiência 3. Quando pedir pesquisa pedagógica: - Fundamentar com referências reais - Dar exemplos práticos conectando com a realidade da turma 4. Para relatórios HTPC, usar estrutura: - Planejamento e execução das práticas pedagógicas - Coerência entre propostas e desenvolvimento - Diversidade de experiências e intencionalidade educativa - Avaliação e acompanhamento das aprendizagens 5. Nunca inventar nomes de crianças, atividades ou datas. Use apenas o que conhece dos documentos. 6. Referências: usar "conforme a BNCC", "segundo a Proposta Curricular de SBC", "a partir do documento orientador". 7. Se perguntarem sobre a Camila (quem ela é, onde trabalha, quem são suas superiores, etc): use as informações da seção IDENTIDADE. Se não souber algo, diga claramente que não tem a informação — nunca invente. Responda SEMPRE em português do Brasil.` }; // Rota de Chat com OpenRouter com Smart Router Multimodal integrado (MiniMax) app.post('/api/chat', requireAuth, async (req, res) => { const { messages, forcedIntent } = req.body; if (!messages || !Array.isArray(messages)) { return res.status(400).json({ error: 'Mensagens inválidas' }); } const lastMessage = messages[messages.length - 1]; const promptText = lastMessage ? lastMessage.content : ''; // 1. Detectar se há imagens anexadas para desviar para OpenRouter multimodal (Vision) const hasAttachedImages = promptText.includes('*[Imagem anexada:'); let intent = forcedIntent || 'TEXT'; // 1.1 Validações cruzadas de coerência com a intenção forçada if (forcedIntent && promptText) { if (forcedIntent === 'IMAGE' && /música|musica|cantiga|canção|cancao|trilha|áudio|audio|ouvir|som|melodia/i.test(promptText)) { res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive'); res.write(`data: ${JSON.stringify({ content: "Ops! Você digitou uma solicitação de música/áudio, mas clicou no botão **Criar Imagem** (🎨). Para compor uma música, por favor, digite sua solicitação e clique no botão **Criar Música** (🎵)!" })}\n\n`); res.write('data: [DONE]\n\n'); return res.end(); } if (forcedIntent === 'AUDIO' && /imagem|desenhe|foto|ilustração|ilustracao|ilustre|desenho|figura|vídeo|video|animação|animacao/i.test(promptText)) { const isVideo = /vídeo|video|animação|animacao/i.test(promptText); res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive'); res.write(`data: ${JSON.stringify({ content: `Ops! Você digitou uma solicitação de ${isVideo ? 'vídeo' : 'imagem'}, mas clicou no botão **Criar Música** (🎵). Para gerar um ${isVideo ? 'vídeo' : 'desenho/imagem'}, por favor, clique no botão correspondente (**${isVideo ? 'Criar Vídeo 🎬' : 'Criar Imagem 🎨'}**)!` })}\n\n`); res.write('data: [DONE]\n\n'); return res.end(); } if (forcedIntent === 'VIDEO' && /música|musica|cantiga|canção|cancao|trilha|áudio|audio|ouvir|som|melodia|imagem|desenhe|foto|ilustração/i.test(promptText)) { const isMusic = /música|musica|cantiga|canção|cancao/i.test(promptText); res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive'); res.write(`data: ${JSON.stringify({ content: `Ops! Você digitou uma solicitação de ${isMusic ? 'música' : 'imagem'}, mas clicou no botão **Criar Vídeo** (🎬). Para realizar essa tarefa, por favor, clique no botão **${isMusic ? 'Criar Música 🎵' : 'Criar Imagem 🎨'}**!` })}\n\n`); res.write('data: [DONE]\n\n'); return res.end(); } } // Executar classificação automática apenas se não houver intenção forçada, não houver imagens anexadas e houver prompt if (!forcedIntent && !hasAttachedImages && promptText && (process.env.OPENROUTER_API_KEY || process.env.GROQ_API_KEY)) { try { const systemDetector = "Você é um classificador de intenções de prompt. Classifique a última mensagem do usuário em uma destas quatro categorias exatas: 'TEXT' (para conversas normais, perguntas, redação de relatórios, planejamentos, textos pedagógicos, respostas gerais), 'IMAGE' (se o usuário estiver pedindo explicitamente para gerar, desenhar, criar ou ilustrar uma imagem, figura, ilustração ou foto), 'AUDIO' (se o usuário estiver pedindo explicitamente para gerar uma música, melodia, trilha sonora, cantiga ou som), ou 'VIDEO' (se o usuário estiver pedindo explicitamente para gerar ou criar um vídeo ou animação). Responda APENAS com a palavra da categoria em letras maiúsculas: TEXT, IMAGE, AUDIO, VIDEO."; let detectResponse; let usingGroqForDetection = false; try { detectResponse = await fetch('https://openrouter.ai/api/v1/chat/completions', { method: 'POST', headers: { 'Authorization': `Bearer ${process.env.OPENROUTER_API_KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ model: process.env.OPENROUTER_MODEL || 'openai/gpt-4.1-nano', messages: [ { role: 'system', content: systemDetector }, { role: 'user', content: promptText } ], max_tokens: 20, temperature: 0.0 }) }); } catch (err) { console.error('[Smart Router] Falha de conexão com OpenRouter para detecção, tentando Groq...', err.message); } // Se OpenRouter falhou ou retornou erro (ex: status != 200), tenta o Groq como fallback if ((!detectResponse || !detectResponse.ok) && process.env.GROQ_API_KEY) { console.log('[Smart Router] Usando Groq (llama-3.1-8b-instant) para classificar intenção...'); try { detectResponse = await fetch('https://api.groq.com/openai/v1/chat/completions', { method: 'POST', headers: { 'Authorization': `Bearer ${process.env.GROQ_API_KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ model: 'llama-3.1-8b-instant', messages: [ { role: 'system', content: systemDetector }, { role: 'user', content: promptText } ], max_tokens: 20, temperature: 0.0 }) }); usingGroqForDetection = detectResponse.ok; } catch (groqErr) { console.error('[Smart Router] Falha de detecção na API da Groq:', groqErr.message); } } if (detectResponse && detectResponse.ok) { const detectData = await detectResponse.json(); const detectedText = detectData.choices?.[0]?.message?.content?.toUpperCase() || 'TEXT'; console.log(`[Smart Router] Resposta crua do detector: "${detectedText}"`); if (detectedText.includes('IMAGE')) { intent = 'IMAGE'; } else if (detectedText.includes('AUDIO') || detectedText.includes('MUSIC')) { intent = 'AUDIO'; } else if (detectedText.includes('VIDEO')) { intent = 'VIDEO'; } else { intent = 'TEXT'; } console.log(`[Smart Router] Intenção detectada para o prompt "${promptText.substring(0, 40)}...": ${intent}`); } else { const errText = await detectResponse.text(); console.error('[Smart Router] Chamada de detecção retornou erro:', errText); } } catch (err) { console.error('Erro no classificador de intenções (usando fallback TEXT):', err); } } // ========================================================================== // FLUXO DE IMAGEM (MiniMax image-01) // ========================================================================== if (intent === 'IMAGE') { res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive'); res.write(`data: ${JSON.stringify({ type: 'media_status', message: 'Iniciando geração de imagem com o OpenRouter...' })}\n\n`); try { // Prompt rewriting: pedir à IA para enriquecer o prompt em inglês let enrichedPrompt = promptText; try { const rewriteResp = await fetch('https://openrouter.ai/api/v1/chat/completions', { method: 'POST', headers: { 'Authorization': `Bearer ${process.env.OPENROUTER_API_KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ model: process.env.OPENROUTER_MODEL || 'openai/gpt-4.1-nano', messages: [ { role: 'system', content: "Reescreva o pedido de imagem do usuário em inglês como um prompt altamente detalhado para geradores de imagem (como Midjourney/Stable Diffusion). Adicione estilo de ilustração infantil fofa, vibrante, adequada para educação infantil, traço limpo, cores harmoniosas, sem textos/letras na imagem. Retorne apenas a tradução/prompt em inglês." }, { role: 'user', content: promptText } ], max_tokens: 150, temperature: 0.7 }) }); if (rewriteResp.ok) { const rewriteData = await rewriteResp.json(); enrichedPrompt = rewriteData.choices?.[0]?.message?.content?.trim() || promptText; console.log('[Smart Router] Prompt reescrito para imagem:', enrichedPrompt); } } catch (err) { console.error('Erro ao reescrever prompt de imagem:', err); } res.write(`data: ${JSON.stringify({ type: 'media_status', message: 'Gerando ilustração pedagógica com o OpenRouter...' })}\n\n`); const response = await fetch('https://openrouter.ai/api/v1/chat/completions', { method: 'POST', headers: { 'Authorization': `Bearer ${process.env.OPENROUTER_API_KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ model: 'x-ai/grok-imagine-image-quality', messages: [ { role: 'user', content: enrichedPrompt } ], modalities: ['image'] }) }); if (!response.ok) { const errText = await response.text(); throw new Error(`OpenRouter respondeu com erro ao gerar imagem: ${errText}`); } const imgData = await response.json(); const imageObj = imgData.choices?.[0]?.message?.images?.[0]; let imageUrl = imageObj?.image_url?.url; if (!imageUrl) { throw new Error('Nenhuma imagem foi retornada pelo OpenRouter'); } // Se for base64, salvar localmente no servidor if (imageUrl.startsWith('data:')) { const fs = require('fs'); const mediaDir = path.join(__dirname, 'public', 'generated-media'); if (!fs.existsSync(mediaDir)) { fs.mkdirSync(mediaDir, { recursive: true }); } const matches = imageUrl.match(/^data:image\/([A-Za-z+-]+);base64,(.+)$/); if (matches && matches.length === 3) { const ext = matches[1]; const base64Data = matches[2]; const fileName = `imagem_${Date.now()}.${ext === 'jpeg' ? 'jpg' : ext}`; const filePath = path.join(mediaDir, fileName); fs.writeFileSync(filePath, Buffer.from(base64Data, 'base64')); imageUrl = `/generated-media/${fileName}`; console.log(`[Smart Router] Imagem salva localmente em: ${imageUrl}`); } } res.write(`data: ${JSON.stringify({ type: 'image', url: imageUrl, originalPrompt: promptText })}\n\n`); res.write('data: [DONE]\n\n'); return res.end(); } catch (error) { console.error('Erro na geração de imagem:', error); res.write(`data: ${JSON.stringify({ content: `Ocorreu um erro ao gerar a imagem: ${error.message}` })}\n\n`); res.write('data: [DONE]\n\n'); return res.end(); } } // ========================================================================== // FLUXO DE ÁUDIO/MÚSICA (Mensagem Pedagógica Amigável - MiniMax Desativado) // ========================================================================== // ========================================================================== // FLUXO DE ÁUDIO/MÚSICA (google/lyria-3-clip-preview) // ========================================================================== if (intent === 'AUDIO') { res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive'); res.write(`data: ${JSON.stringify({ type: 'media_status', message: 'Compondo e gerando música pedagógica com Lyria AI...' })}\n\n`); try { if (!process.env.OPENROUTER_API_KEY) { throw new Error('OPENROUTER_API_KEY não configurada no servidor.'); } const response = await fetch('https://openrouter.ai/api/v1/chat/completions', { method: 'POST', headers: { 'Authorization': `Bearer ${process.env.OPENROUTER_API_KEY}`, 'HTTP-Referer': 'https://camila.reifonas.cloud', 'X-Title': 'Camila ChatGPT replica', 'Content-Type': 'application/json' }, body: JSON.stringify({ model: 'google/lyria-3-clip-preview', messages: [ { role: 'user', content: promptText } ], modalities: ['audio'], stream: true }) }); if (!response.ok) { const errorText = await response.text(); throw new Error(`Erro na API do OpenRouter (Lyria): ${errorText}`); } const reader = response.body.getReader(); const decoder = new TextDecoder('utf-8'); let buffer = ''; let audioBase64Chunks = []; let textContent = ''; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split('\n'); 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); const delta = parsed.choices?.[0]?.delta; if (delta) { if (delta.content) { textContent += delta.content; res.write(`data: ${JSON.stringify({ content: delta.content })}\n\n`); } if (delta.audio?.data) { audioBase64Chunks.push(delta.audio.data); } } } catch (e) { // Ignorar } } } } // Processar restante do buffer if (buffer) { const cleanLine = buffer.trim(); if (cleanLine.startsWith('data: ')) { const dataContent = cleanLine.slice(6); if (dataContent !== '[DONE]') { try { const parsed = JSON.parse(dataContent); const delta = parsed.choices?.[0]?.delta; if (delta) { if (delta.content) { textContent += delta.content; res.write(`data: ${JSON.stringify({ content: delta.content })}\n\n`); } if (delta.audio?.data) { audioBase64Chunks.push(delta.audio.data); } } } catch (e) { // Ignorar } } } } if (audioBase64Chunks.length > 0) { const fullAudioBase64 = audioBase64Chunks.join(''); // Salvar em arquivo no servidor const fs = require('fs'); const mediaDir = path.join(__dirname, 'public', 'generated-media'); if (!fs.existsSync(mediaDir)) { fs.mkdirSync(mediaDir, { recursive: true }); } const fileName = `musica_${Date.now()}.mp3`; const filePath = path.join(mediaDir, fileName); fs.writeFileSync(filePath, Buffer.from(fullAudioBase64, 'base64')); const audioUrl = `/generated-media/${fileName}`; console.log(`[Smart Router] Música salva localmente em: ${audioUrl}`); // Envia a mídia estruturada para o frontend res.write(`data: ${JSON.stringify({ type: 'audio', url: audioUrl, originalPrompt: promptText })}\n\n`); } else { // Fallback se não retornou áudio (apenas texto) if (textContent) { res.write(`data: ${JSON.stringify({ content: '\n\n*(Nota: O áudio não pôde ser gerado por limitações do modelo, mas a letra acima foi composta com carinho!)*' })}\n\n`); } else { throw new Error('Nenhum dado de áudio ou texto foi retornado pelo modelo Lyria.'); } } res.write('data: [DONE]\n\n'); return res.end(); } catch (error) { console.error('Erro na resposta de áudio com Lyria:', error); res.write(`data: ${JSON.stringify({ content: `Desculpe, ocorreu um erro ao gerar a música: ${error.message}. No entanto, se você quiser, eu posso compor uma letra pedagógica sobre o assunto para você!` })}\n\n`); res.write('data: [DONE]\n\n'); return res.end(); } } // ========================================================================== // FLUXO DE VÍDEO (google/veo-3.1-lite via OpenRouter) // ========================================================================== if (intent === 'VIDEO') { res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive'); res.write(`data: ${JSON.stringify({ type: 'media_status', message: 'Iniciando geração de vídeo com o modelo google/veo-3.1-lite...' })}\n\n`); try { // 1. Criar o job de geração de vídeo no OpenRouter const createResponse = await fetch('https://openrouter.ai/api/v1/videos', { method: 'POST', headers: { 'Authorization': `Bearer ${process.env.OPENROUTER_API_KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ model: 'google/veo-3.1-lite', prompt: promptText }) }); if (!createResponse.ok) { const errorText = await createResponse.ok ? '' : await createResponse.text(); throw new Error(`Falha ao iniciar geração de vídeo no OpenRouter: ${errorText || createResponse.statusText}`); } const jobData = await createResponse.json(); const jobId = jobData.id; const pollingUrl = jobData.polling_url || `https://openrouter.ai/api/v1/videos/${jobId}`; console.log(`[Smart Router] Job de vídeo iniciado no OpenRouter com ID: ${jobId}`); res.write(`data: ${JSON.stringify({ type: 'media_status', message: 'Vídeo enfileirado no OpenRouter, aguardando processamento...' })}\n\n`); // 2. Polling loop para monitorar a conclusão do job let status = 'queued'; let videoUrl = null; let attempts = 0; const maxAttempts = 30; // 3 minutos no total (6s por tentativa) while (attempts < maxAttempts) { attempts++; // Aguarda 6 segundos antes de verificar novamente await new Promise(resolve => setTimeout(resolve, 6000)); console.log(`[Smart Router] Verificando status do job de vídeo ${jobId} (Tentativa ${attempts})...`); const statusResponse = await fetch(pollingUrl, { headers: { 'Authorization': `Bearer ${process.env.OPENROUTER_API_KEY}` } }); if (!statusResponse.ok) { console.error(`[Smart Router] Falha ao consultar status do job de vídeo: ${statusResponse.statusText}`); continue; } const statusData = await statusResponse.json(); status = statusData.status; if (status === 'completed') { videoUrl = statusData.unsigned_urls?.[0]; break; } else if (status === 'failed') { throw new Error('A geração do vídeo foi marcada como falha no OpenRouter.'); } else { // in_progress / queued / processing const progressPercent = Math.round((attempts / maxAttempts) * 100); res.write(`data: ${JSON.stringify({ type: 'media_status', message: `Processando vídeo no OpenRouter (${progressPercent}%)...` })}\n\n`); } } if (!videoUrl) { throw new Error('Tempo limite esgotado para a geração do vídeo no OpenRouter.'); } res.write(`data: ${JSON.stringify({ type: 'media_status', message: 'Vídeo concluído! Baixando arquivo para o servidor...' })}\n\n`); // 3. Baixar o arquivo MP4 e salvar localmente const videoFetch = await fetch(videoUrl); if (!videoFetch.ok) { throw new Error('Falha ao efetuar download do arquivo de vídeo temporário'); } const videoBuffer = await videoFetch.arrayBuffer(); const fs = require('fs'); const mediaDir = path.join(__dirname, 'public', 'generated-media'); if (!fs.existsSync(mediaDir)) { fs.mkdirSync(mediaDir, { recursive: true }); } const fileName = `video_${Date.now()}.mp4`; const filePath = path.join(mediaDir, fileName); fs.writeFileSync(filePath, Buffer.from(videoBuffer)); const localVideoUrl = `/generated-media/${fileName}`; console.log(`[Smart Router] Vídeo salvo localmente em: ${localVideoUrl}`); res.write(`data: ${JSON.stringify({ type: 'video', url: localVideoUrl, originalPrompt: promptText })}\n\n`); res.write('data: [DONE]\n\n'); return res.end(); } catch (error) { console.error('Erro na geração de vídeo:', error); res.write(`data: ${JSON.stringify({ content: `Desculpe, ocorreu um erro ao gerar o vídeo: ${error.message}.` })}\n\n`); res.write('data: [DONE]\n\n'); return res.end(); } } // ========================================================================== // FLUXO PADRÃO DE TEXTO (OpenRouter com SSE streaming original, com fallback Groq) // ========================================================================== const agentConfig = readAgentConfig(); const agentName = agentConfig.agentName || "Kemily"; const temperaturePreset = agentConfig.temperaturePreset || 'equilibrado'; const temperature = TEMPERATURE_PRESETS[temperaturePreset]?.value || 0.6; const knowledgeText = getKnowledgeAsText(); const dynamicSystemPrompt = { role: "system", content: KEMILY_SYSTEM_PROMPT.content .replace(/Você é a Kemily/g, `Você é a ${agentName}`) .replace('INFORMAÇÕES PESSOAIS:', `${knowledgeText}INFORMAÇÕES PESSOAIS:`) }; const enrichedMessages = [dynamicSystemPrompt, ...messages]; try { let chatResponse; let providerUsed = 'OpenRouter'; try { chatResponse = await fetch('https://openrouter.ai/api/v1/chat/completions', { method: 'POST', headers: { 'Authorization': `Bearer ${process.env.OPENROUTER_API_KEY}`, 'HTTP-Referer': 'https://camila.reifonas.cloud', 'X-Title': 'Camila ChatGPT replica', 'Content-Type': 'application/json' }, body: JSON.stringify({ model: process.env.OPENROUTER_MODEL || 'openai/gpt-4.1-nano', messages: enrichedMessages, stream: true, temperature }) }); } catch (e) { console.error('Erro na conexão com OpenRouter, tentando Groq...', e.message); } // Se o OpenRouter falhar ou retornar erro (ex: status != 200), caímos no fallback do Groq if ((!chatResponse || !chatResponse.ok) && process.env.GROQ_API_KEY) { console.log('[Smart Router] Usando Groq (llama-3.3-70b-versatile) como fallback de streaming de texto...'); providerUsed = 'Groq'; try { chatResponse = await fetch('https://api.groq.com/openai/v1/chat/completions', { method: 'POST', headers: { 'Authorization': `Bearer ${process.env.GROQ_API_KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ model: process.env.GROQ_MODEL || 'llama-3.3-70b-versatile', messages: enrichedMessages, stream: true, temperature }) }); } catch (groqErr) { console.error('[Smart Router] Erro crítico: Falha no fallback do Groq também:', groqErr.message); } } if (!chatResponse || !chatResponse.ok) { const errorText = chatResponse ? await chatResponse.text() : 'Sem conexão com os servidores de IA'; console.error(`Erro na API de IA (${providerUsed}):`, errorText); return res.status(500).json({ error: `Erro ao processar requisição na API de IA (${providerUsed})` }); } // Configurar headers para Server-Sent Events (SSE) res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive'); const reader = chatResponse.body.getReader(); const decoder = new TextDecoder('utf-8'); let buffer = ''; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split('\n'); // Manter a última linha 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]') { res.write('data: [DONE]\n\n'); continue; } try { const parsed = JSON.parse(dataContent); const content = parsed.choices?.[0]?.delta?.content || ''; if (content) { res.write(`data: ${JSON.stringify({ content })}\n\n`); } } catch (e) { // Ignorar linhas incompletas ou JSON inválido } } } } // Enviar qualquer conteúdo restante no buffer if (buffer) { const cleanLine = buffer.trim(); if (cleanLine.startsWith('data: ')) { const dataContent = cleanLine.slice(6); if (dataContent !== '[DONE]') { try { const parsed = JSON.parse(dataContent); const content = parsed.choices?.[0]?.delta?.content || ''; if (content) { res.write(`data: ${JSON.stringify({ content })}\n\n`); } } catch (e) {} } } } res.write('data: [DONE]\n\n'); res.end(); } catch (error) { console.error('Erro na rota de chat:', error); res.status(500).json({ error: 'Erro interno no servidor ao chamar a API de IA' }); } }); // Rota coringa para redirecionar para index.html app.get('*', requireAuth, (req, res) => { res.sendFile(path.join(__dirname, 'public', 'index.html')); }); app.listen(PORT, '0.0.0.0', () => { console.log(`Servidor rodando na porta ${PORT}`); });