require('dotenv').config(); const express = require('express'); const cookieParser = require('cookie-parser'); const path = require('path'); const app = express(); const PORT = process.env.PORT || 3000; const ACCESS_PASSWORD = process.env.ACCESS_PASSWORD || 'puglindo26'; app.use(express.json()); 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'); } } }; // 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 de educação infantil da Profª Camila Martella Gasparini Reifonas. 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) 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. ═══════════════════════════════════════════════ 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, coherê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 - Grupose para 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,替代 Mind Lab 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 - Hypothesis 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". 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 } = 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 = 'TEXT'; // Executar classificação apenas se houver prompt de texto e não houver imagens anexadas if (!hasAttachedImages && promptText && process.env.MINIMAX_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."; const 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: 5, temperature: 0.0 }) }); if (detectResponse.ok) { const detectData = await detectResponse.json(); const detectedText = detectData.choices?.[0]?.message?.content?.trim() || 'TEXT'; if (['TEXT', 'IMAGE', 'AUDIO', 'VIDEO'].includes(detectedText)) { intent = detectedText; console.log(`[Smart Router] Intenção detectada para o prompt "${promptText.substring(0, 40)}...": ${intent}`); } } } 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 a MiniMax...' })}\n\n`); try { // Prompt rewriting: pedir à IA para enriquecer o prompt em inglês para a MiniMax 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...' })}\n\n`); const response = await fetch(`${process.env.MINIMAX_API_BASE}/image_generation`, { method: 'POST', headers: { 'Authorization': `Bearer ${process.env.MINIMAX_API_KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ model: 'image-01', prompt: enrichedPrompt, aspect_ratio: '1:1', response_format: 'url' }) }); if (!response.ok) { const errText = await response.text(); throw new Error(`MiniMax respondeu com erro: ${errText}`); } const imgData = await response.json(); const imageUrl = imgData.data?.image_url?.[0]; if (!imageUrl) { throw new Error('Nenhuma imagem foi gerada pela API do MiniMax'); } 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 (MiniMax music-2.6) // ========================================================================== 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 a letra da música infantil...' })}\n\n`); try { // Gerar letra curta e estilo musical com OpenRouter let lyrics = ''; let stylePrompt = ''; try { const composerResp = 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: "Crie uma letra de música infantil em português baseada no prompt do usuário. A música deve ser muito curta (apenas 2 estrofes e 1 refrão) usando as tags estruturais [Verse] e [Chorus]. Crie também um estilo de trilha adequado em inglês (ex: 'Acoustic children song, happy, acoustic guitar, soft piano, female vocals'). Retorne obrigatoriamente um JSON no formato: {\"lyrics\": \"[Verse]...\", \"style\": \"style in english\"}" }, { role: 'user', content: promptText } ], response_format: { type: 'json_object' } }) }); if (composerResp.ok) { const compData = await composerResp.json(); const parsedComp = JSON.parse(compData.choices?.[0]?.message?.content || '{}'); lyrics = parsedComp.lyrics || `[Verse]\n${promptText}`; stylePrompt = parsedComp.style || 'acoustic children song, happy, female vocals'; console.log('[Smart Router] Letra e estilo compostos:', { stylePrompt, lyrics }); } } catch (err) { console.error('Erro ao compor letra de música:', err); lyrics = `[Verse]\n${promptText}`; stylePrompt = 'children song, acoustic guitar, female vocals'; } res.write(`data: ${JSON.stringify({ type: 'media_status', message: 'Gerando áudio da música com a MiniMax...' })}\n\n`); const response = await fetch(`${process.env.MINIMAX_API_BASE}/music_generation`, { method: 'POST', headers: { 'Authorization': `Bearer ${process.env.MINIMAX_API_KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ model: 'music-2.6', prompt: stylePrompt, lyrics: lyrics, is_instrumental: false, lyrics_optimizer: true }) }); if (!response.ok) { const errText = await response.text(); throw new Error(`MiniMax respondeu com erro na música: ${errText}`); } const musicData = await response.json(); let audioUrl = musicData.data?.audio_url; // Se a API retornar os dados binários codificados em hex if (!audioUrl && musicData.data?.audio) { const audioBase64 = Buffer.from(musicData.data.audio, 'hex').toString('base64'); audioUrl = `data:audio/mp3;base64,${audioBase64}`; } if (!audioUrl) { throw new Error('Nenhum áudio de música foi gerado pela API da MiniMax'); } res.write(`data: ${JSON.stringify({ type: 'audio', url: audioUrl, originalPrompt: promptText })}\n\n`); res.write('data: [DONE]\n\n'); return res.end(); } catch (error) { console.error('Erro na geração de música:', error); res.write(`data: ${JSON.stringify({ content: `Ocorreu um erro ao compor a música: ${error.message}` })}\n\n`); res.write('data: [DONE]\n\n'); return res.end(); } } // ========================================================================== // FLUXO DE VÍDEO (MiniMax video-01 - Assíncrono) // ========================================================================== 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 na MiniMax...' })}\n\n`); try { // Prompt rewriting em inglês para vídeo 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 vídeo do usuário em inglês como um prompt altamente descritivo e fluído para geradores de vídeo. Adicione estilo de animação 3D amigável, cores vibrantes, infantil, movimentos suaves. Retorne apenas 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 vídeo:', enrichedPrompt); } } catch (err) { console.error('Erro ao reescrever prompt de vídeo:', err); } res.write(`data: ${JSON.stringify({ type: 'media_status', message: 'Criando tarefa de renderização...' })}\n\n`); const response = await fetch(`${process.env.MINIMAX_API_BASE}/video_generation`, { method: 'POST', headers: { 'Authorization': `Bearer ${process.env.MINIMAX_API_KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ model: 'video-01', prompt: enrichedPrompt }) }); if (!response.ok) { const errText = await response.text(); throw new Error(`MiniMax respondeu com erro na geração de vídeo: ${errText}`); } const jobData = await response.json(); const taskId = jobData.task_id; if (!taskId) { throw new Error('Nenhum ID de tarefa foi retornado pelo MiniMax'); } // Polling do status do vídeo let videoUrl = null; let attempts = 0; const maxAttempts = 30; // ~90 segundos máximo (3 segundos de intervalo) while (attempts < maxAttempts) { res.write(`data: ${JSON.stringify({ type: 'media_status', message: `Processando vídeo... (${attempts * 3}s / 90s)` })}\n\n`); const checkResp = await fetch(`${process.env.MINIMAX_API_BASE}/query/video_generation?task_id=${taskId}`, { headers: { 'Authorization': `Bearer ${process.env.MINIMAX_API_KEY}` } }); if (checkResp.ok) { const checkData = await checkResp.json(); console.log(`[Smart Router] Status do vídeo job ${taskId}:`, checkData.status); if (checkData.status === 'Success') { const fileId = checkData.file_id; // Obter link de download real via Files API const fileResp = await fetch(`${process.env.MINIMAX_API_BASE}/files/retrieve?file_id=${fileId}`, { headers: { 'Authorization': `Bearer ${process.env.MINIMAX_API_KEY}` } }); if (fileResp.ok) { const fileData = await fileResp.json(); videoUrl = fileData.file?.download_url; } break; } else if (checkData.status === 'Fail') { throw new Error('A API da MiniMax falhou em renderizar este vídeo.'); } } await new Promise(r => setTimeout(r, 3000)); attempts++; } if (!videoUrl) { throw new Error('Tempo limite de renderização de vídeo excedido'); } res.write(`data: ${JSON.stringify({ type: 'video', url: videoUrl, 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: `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) // ========================================================================== const enrichedMessages = [KEMILY_SYSTEM_PROMPT, ...messages]; try { const openrouterUrl = 'https://openrouter.ai/api/v1/chat/completions'; const response = await fetch(openrouterUrl, { 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, messages: enrichedMessages, stream: true }) }); if (!response.ok) { const errorText = await response.text(); console.error('Erro na API do OpenRouter:', errorText); return res.status(response.status).json({ error: 'Erro ao processar requisição na API do OpenRouter' }); } // 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 = response.body.getReader(); const decoder = new TextDecoder('utf-8'); let buffer = ''; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split('\n'); // 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}`); });