Files
camila/server.js
T

961 lines
40 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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({ 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');
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);
}
}
// 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 } = 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"
};
writeAgentConfig(config);
res.json({ success: true, config });
});
// 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 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, 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 dynamicSystemPrompt = {
role: "system",
content: KEMILY_SYSTEM_PROMPT.content.replace(/Você é a Kemily/g, `Você é a ${agentName}`)
};
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
})
});
} 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
})
});
} 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}`);
});