feat: implementado Smart Router Multimodal via MiniMax e OpenRouter

This commit is contained in:
2026-05-26 17:05:25 +00:00
parent d4a5046826
commit 9c2ec032b1
5 changed files with 1179 additions and 9 deletions
+338 -3
View File
@@ -37,8 +37,9 @@ app.get('/login', (req, res) => {
// API de status do modelo
app.get('/api/status', requireAuth, (req, res) => {
res.json({
model: process.env.OPENAI_MODEL || 'openai/gpt-4.1-nano',
model: process.env.OPENROUTER_MODEL || 'openai/gpt-4.1-nano',
provider: 'openrouter',
minimax_available: !!process.env.MINIMAX_API_KEY,
timestamp: new Date().toISOString()
});
});
@@ -252,7 +253,7 @@ Responda SEMPRE em português do Brasil.`
};
// Rota de Chat com OpenRouter (Streaming nativo)
// Rota de Chat com OpenRouter com Smart Router Multimodal integrado (MiniMax)
app.post('/api/chat', requireAuth, async (req, res) => {
const { messages } = req.body;
@@ -260,7 +261,341 @@ app.post('/api/chat', requireAuth, async (req, res) => {
return res.status(400).json({ error: 'Mensagens inválidas' });
}
// Injetar system prompt da Kemily no início da conversa
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 {