feat: implementado Smart Router Multimodal via MiniMax e OpenRouter
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user