feat: migrar texto/imagem/música para MiniMax M3 + image-01 + music-2.0
- Adiciona helper callMinimax() para simplificar chamadas de texto - Chat principal: M3 primário (não-streaming com SSE adaptado) - Intent detector: M3 → OpenRouter → Groq (cascata) - Smart Report: M3 primário → OpenRouter → Groq (cascata) - Compilar Relatório: M3 primário → OpenRouter → Gemini (cascata) - Resumo de observações: M3 direto - Imagem: M3 reescreve prompt + MiniMax image-01 nativo - Música: M3 compõe letra/estilo + MiniMax music-2.0 nativo - Vídeo: MANTIDO no OpenRouter (MiniMax bloqueado no plano Starter) - Adiciona MINIMAX_MODEL=MiniMax-M3 no .env
This commit is contained in:
@@ -12,3 +12,4 @@ GOOGLE_AI_API_KEY=AIzaSyACelmI6-Bxc7TZ0sAzzgLd_F8VBexOjcQ
|
||||
GOOGLE_AI_MODEL=gemini-2.5-flash
|
||||
|
||||
DATABASE_URL=postgresql://postgres:Xz0oyb6ArGYG5uAVTVwcvJxRrMuT7EIJ@supabase-db-h0oggskgs0ws0sco8kc4s8ws:5432/postgres
|
||||
MINIMAX_MODEL=MiniMax-M3
|
||||
|
||||
@@ -14,6 +14,48 @@ const multerUpload = multer({
|
||||
limits: { fileSize: 25 * 1024 * 1024 } // 25MB max
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// HELPER: chamada à API MiniMax (texto / classificação / reescrita de prompt)
|
||||
// Substitui OpenRouter para todas as funções de TEXTO a partir de jun/2026.
|
||||
// Imagem, música e vídeo continuam usando endpoints nativos MiniMax em /v1/
|
||||
// ============================================================================
|
||||
async function callMinimax({ messages, temperature = 0.5, max_tokens = 1500, system = null, model = null }) {
|
||||
const baseUrl = (process.env.MINIMAX_API_BASE || 'https://api.minimax.io/v1').replace(/\/v1\/?$/, '');
|
||||
const chosenModel = model || process.env.MINIMAX_MODEL || 'MiniMax-M3';
|
||||
const finalMessages = system
|
||||
? [{ role: 'system', content: system }, ...messages]
|
||||
: messages;
|
||||
|
||||
const res = await fetch(`${baseUrl}/anthropic/v1/messages`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-Api-Key': process.env.MINIMAX_API_KEY,
|
||||
'Content-Type': 'application/json',
|
||||
'anthropic-version': '2023-06-01'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: chosenModel,
|
||||
max_tokens,
|
||||
temperature,
|
||||
messages: finalMessages
|
||||
})
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const txt = await res.text();
|
||||
throw new Error(`MiniMax respondeu ${res.status}: ${txt}`);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
// Extrai só os blocos de texto (ignora thinking blocks)
|
||||
const text = (data.content || [])
|
||||
.filter(b => b.type === 'text')
|
||||
.map(b => b.text)
|
||||
.join('')
|
||||
.trim();
|
||||
return { text, raw: data };
|
||||
}
|
||||
|
||||
app.use(express.json({ limit: '50mb' }));
|
||||
app.use(express.urlencoded({ limit: '50mb', extended: true }));
|
||||
app.use(cookieParser(process.env.SESSION_SECRET || 'camila-secret'));
|
||||
@@ -503,9 +545,23 @@ Retorne APENAS o JSON válido:`;
|
||||
|
||||
let rawContent = '';
|
||||
|
||||
// Tentar OpenRouter
|
||||
// Tentar MiniMax (M3) como primário
|
||||
try {
|
||||
console.log('[Smart Report] Tentando gerar relatório via OpenRouter...');
|
||||
console.log('[Smart Report] Tentando gerar relatório via MiniMax M3...');
|
||||
const result = await callMinimax({
|
||||
messages: [{ role: 'user', content: reportPrompt }],
|
||||
temperature: 0.5,
|
||||
max_tokens: 1500
|
||||
});
|
||||
rawContent = result.text;
|
||||
} catch (err) {
|
||||
console.error('[Smart Report] Erro de conexão com MiniMax:', err.message);
|
||||
}
|
||||
|
||||
// Fallback para OpenRouter se MiniMax falhar
|
||||
if (!rawContent && process.env.OPENROUTER_API_KEY) {
|
||||
try {
|
||||
console.log('[Smart Report] Usando OpenRouter como fallback...');
|
||||
const reportRes = await fetch('https://openrouter.ai/api/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -529,6 +585,7 @@ Retorne APENAS o JSON válido:`;
|
||||
} catch (err) {
|
||||
console.error('[Smart Report] Erro de conexão com OpenRouter:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback para Groq se OpenRouter falhar
|
||||
if (!rawContent && process.env.GROQ_API_KEY) {
|
||||
@@ -1005,9 +1062,23 @@ Orientações adicionais:
|
||||
|
||||
let generatedReport = '';
|
||||
|
||||
// Tenta OpenRouter
|
||||
// Tenta MiniMax (M3) como primário
|
||||
try {
|
||||
console.log('[Compilar Relatório] Chamando OpenRouter...');
|
||||
console.log('[Compilar Relatório] Chamando MiniMax M3...');
|
||||
const result = await callMinimax({
|
||||
messages: [{ role: 'user', content: systemPrompt }],
|
||||
temperature: 0.5,
|
||||
max_tokens: 6000
|
||||
});
|
||||
generatedReport = result.text;
|
||||
} catch (err) {
|
||||
console.error('[Compilar Relatório] Erro no MiniMax:', err.message);
|
||||
}
|
||||
|
||||
// Fallback para OpenRouter se MiniMax falhar
|
||||
if (!generatedReport && process.env.OPENROUTER_API_KEY) {
|
||||
try {
|
||||
console.log('[Compilar Relatório] Fallback para OpenRouter...');
|
||||
const response = await fetch('https://openrouter.ai/api/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -1030,6 +1101,7 @@ Orientações adicionais:
|
||||
} catch (err) {
|
||||
console.error('[Compilar Relatório] Erro no OpenRouter:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback para Google Gemini
|
||||
if (!generatedReport && process.env.GOOGLE_AI_API_KEY) {
|
||||
@@ -1246,22 +1318,12 @@ GERE UM RESUMO ESTRUTURADO COM:
|
||||
-Máximo 300 palavras
|
||||
-Seja objetivo e baseado nas evidências das observações`;
|
||||
|
||||
const compileRes = 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',
|
||||
const compileResult = await callMinimax({
|
||||
messages: [{ role: 'user', content: compilePrompt }],
|
||||
temperature: 0.4,
|
||||
max_tokens: 800
|
||||
})
|
||||
});
|
||||
|
||||
const compileData = await compileRes.json();
|
||||
const resumoText = compileData.choices?.[0]?.message?.content || '';
|
||||
const resumoText = compileResult.text;
|
||||
|
||||
res.json({
|
||||
crianca,
|
||||
@@ -1851,15 +1913,34 @@ app.post('/api/chat', requireAuth, async (req, res) => {
|
||||
}
|
||||
|
||||
// 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)) {
|
||||
if (!forcedIntent && !hasAttachedImages && promptText && (process.env.MINIMAX_API_KEY || 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 detectedText = null;
|
||||
let usingGroqForDetection = false;
|
||||
|
||||
// 1) Tentar MiniMax M3 como primário
|
||||
if (process.env.MINIMAX_API_KEY) {
|
||||
try {
|
||||
detectResponse = await fetch('https://openrouter.ai/api/v1/chat/completions', {
|
||||
console.log('[Smart Router] Detectando intenção via MiniMax M3...');
|
||||
const r = await callMinimax({
|
||||
messages: [{ role: 'user', content: promptText }],
|
||||
system: systemDetector,
|
||||
temperature: 0.0,
|
||||
max_tokens: 20
|
||||
});
|
||||
detectedText = r.text;
|
||||
} catch (err) {
|
||||
console.error('[Smart Router] Falha na detecção via MiniMax, tentando OpenRouter...', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Fallback: OpenRouter
|
||||
if (!detectedText && process.env.OPENROUTER_API_KEY) {
|
||||
try {
|
||||
console.log('[Smart Router] Detectando intenção via OpenRouter...');
|
||||
const detectResponse = await fetch('https://openrouter.ai/api/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${process.env.OPENROUTER_API_KEY}`,
|
||||
@@ -1875,15 +1956,20 @@ app.post('/api/chat', requireAuth, async (req, res) => {
|
||||
temperature: 0.0
|
||||
})
|
||||
});
|
||||
if (detectResponse.ok) {
|
||||
const detectData = await detectResponse.json();
|
||||
detectedText = detectData.choices?.[0]?.message?.content?.toUpperCase() || null;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Smart Router] Falha de conexão com OpenRouter para detecção, tentando Groq...', err.message);
|
||||
console.error('[Smart Router] Falha de detecção na OpenRouter, 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...');
|
||||
// 3) Fallback final: Groq
|
||||
if (!detectedText && process.env.GROQ_API_KEY) {
|
||||
console.log('[Smart Router] Detectando intenção via Groq (llama-3.1-8b-instant)...');
|
||||
try {
|
||||
detectResponse = await fetch('https://api.groq.com/openai/v1/chat/completions', {
|
||||
const detectResponse = await fetch('https://api.groq.com/openai/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${process.env.GROQ_API_KEY}`,
|
||||
@@ -1899,30 +1985,32 @@ app.post('/api/chat', requireAuth, async (req, res) => {
|
||||
temperature: 0.0
|
||||
})
|
||||
});
|
||||
usingGroqForDetection = detectResponse.ok;
|
||||
if (detectResponse.ok) {
|
||||
const detectData = await detectResponse.json();
|
||||
detectedText = detectData.choices?.[0]?.message?.content?.toUpperCase() || null;
|
||||
usingGroqForDetection = !!detectedText;
|
||||
}
|
||||
} 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) {
|
||||
const txt = detectedText.toUpperCase();
|
||||
console.log(`[Smart Router] Resposta crua do detector: "${txt}"`);
|
||||
|
||||
if (detectedText.includes('IMAGE')) {
|
||||
if (txt.includes('IMAGE')) {
|
||||
intent = 'IMAGE';
|
||||
} else if (detectedText.includes('AUDIO') || detectedText.includes('MUSIC')) {
|
||||
} else if (txt.includes('AUDIO') || txt.includes('MUSIC')) {
|
||||
intent = 'AUDIO';
|
||||
} else if (detectedText.includes('VIDEO')) {
|
||||
} else if (txt.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);
|
||||
console.error('[Smart Router] Nenhum detector respondeu; usando TEXT como fallback.');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Erro no classificador de intenções (usando fallback TEXT):', err);
|
||||
@@ -1930,95 +2018,77 @@ app.post('/api/chat', requireAuth, async (req, res) => {
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// FLUXO DE IMAGEM (MiniMax image-01)
|
||||
// FLUXO DE IMAGEM (MiniMax M3 + 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`);
|
||||
res.write(`data: ${JSON.stringify({ type: 'media_status', message: 'Iniciando geração de imagem com o MiniMax...' })}\n\n`);
|
||||
|
||||
try {
|
||||
// Prompt rewriting: pedir à IA para enriquecer o prompt em inglês
|
||||
// Prompt rewriting: pedir ao M3 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
|
||||
})
|
||||
const r = await callMinimax({
|
||||
system: "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.",
|
||||
messages: [{ role: 'user', content: promptText }],
|
||||
temperature: 0.7,
|
||||
max_tokens: 200
|
||||
});
|
||||
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);
|
||||
}
|
||||
enrichedPrompt = r.text || promptText;
|
||||
console.log('[Smart Router] Prompt reescrito para imagem (M3):', enrichedPrompt);
|
||||
} catch (err) {
|
||||
console.error('Erro ao reescrever prompt de imagem:', err);
|
||||
console.error('Erro ao reescrever prompt de imagem via M3:', err.message);
|
||||
}
|
||||
|
||||
res.write(`data: ${JSON.stringify({ type: 'media_status', message: 'Gerando ilustração pedagógica com o OpenRouter...' })}\n\n`);
|
||||
res.write(`data: ${JSON.stringify({ type: 'media_status', message: 'Gerando ilustração pedagógica com MiniMax image-01...' })}\n\n`);
|
||||
|
||||
const response = await fetch('https://openrouter.ai/api/v1/chat/completions', {
|
||||
// 1) Gerar imagem via endpoint nativo da MiniMax
|
||||
const minmBase = (process.env.MINIMAX_API_BASE || 'https://api.minimax.io/v1').replace(/\/v1\/?$/, '');
|
||||
const genResp = await fetch(`${minmBase}/v1/image_generation`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${process.env.OPENROUTER_API_KEY}`,
|
||||
'Authorization': `Bearer ${process.env.MINIMAX_API_KEY}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'x-ai/grok-imagine-image-quality',
|
||||
messages: [
|
||||
{ role: 'user', content: enrichedPrompt }
|
||||
],
|
||||
modalities: ['image']
|
||||
model: 'image-01',
|
||||
prompt: enrichedPrompt,
|
||||
n: 1
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errText = await response.text();
|
||||
throw new Error(`OpenRouter respondeu com erro ao gerar imagem: ${errText}`);
|
||||
if (!genResp.ok) {
|
||||
const errText = await genResp.text();
|
||||
throw new Error(`MiniMax image-01 respondeu erro: ${errText}`);
|
||||
}
|
||||
|
||||
const imgData = await response.json();
|
||||
const imageObj = imgData.choices?.[0]?.message?.images?.[0];
|
||||
let imageUrl = imageObj?.image_url?.url;
|
||||
|
||||
const genData = await genResp.json();
|
||||
const imageUrl = genData.data?.image_urls?.[0];
|
||||
if (!imageUrl) {
|
||||
throw new Error('Nenhuma imagem foi retornada pelo OpenRouter');
|
||||
throw new Error('Nenhuma URL de imagem retornada pela MiniMax');
|
||||
}
|
||||
|
||||
// Se for base64, salvar localmente no servidor
|
||||
if (imageUrl.startsWith('data:')) {
|
||||
// 2) Baixar e salvar localmente no servidor (a URL expira em pouco tempo)
|
||||
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 imgFetch = await fetch(imageUrl);
|
||||
if (!imgFetch.ok) {
|
||||
throw new Error(`Falha ao baixar imagem: ${imgFetch.statusText}`);
|
||||
}
|
||||
const imgBuffer = Buffer.from(await imgFetch.arrayBuffer());
|
||||
const fileName = `imagem_${Date.now()}.jpg`;
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
fs.writeFileSync(filePath, imgBuffer);
|
||||
const localUrl = `/generated-media/${fileName}`;
|
||||
console.log(`[Smart Router] Imagem salva em: ${localUrl}`);
|
||||
|
||||
res.write(`data: ${JSON.stringify({ type: 'image', url: imageUrl, originalPrompt: promptText })}\n\n`);
|
||||
res.write(`data: ${JSON.stringify({ type: 'image', url: localUrl, originalPrompt: promptText })}\n\n`);
|
||||
res.write('data: [DONE]\n\n');
|
||||
return res.end();
|
||||
|
||||
@@ -2031,113 +2101,82 @@ app.post('/api/chat', requireAuth, async (req, res) => {
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// FLUXO DE ÁUDIO/MÚSICA (Mensagem Pedagógica Amigável - MiniMax Desativado)
|
||||
// ==========================================================================
|
||||
// ==========================================================================
|
||||
// FLUXO DE ÁUDIO/MÚSICA (google/lyria-3-clip-preview)
|
||||
// FLUXO DE ÁUDIO/MÚSICA (MiniMax music-2.0)
|
||||
// ==========================================================================
|
||||
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`);
|
||||
res.write(`data: ${JSON.stringify({ type: 'media_status', message: 'Compondo e gerando música pedagógica com MiniMax music-2.0...' })}\n\n`);
|
||||
|
||||
try {
|
||||
if (!process.env.OPENROUTER_API_KEY) {
|
||||
throw new Error('OPENROUTER_API_KEY não configurada no servidor.');
|
||||
if (!process.env.MINIMAX_API_KEY) {
|
||||
throw new Error('MINIMAX_API_KEY não configurada no servidor.');
|
||||
}
|
||||
|
||||
const response = await fetch('https://openrouter.ai/api/v1/chat/completions', {
|
||||
// Estratégia: usar o M3 para gerar a letra (em PT-BR) e um prompt descritivo do estilo,
|
||||
// depois enviar pro music-2.0 gerar a música.
|
||||
let lyrics = '';
|
||||
let stylePrompt = '';
|
||||
try {
|
||||
const r = await callMinimax({
|
||||
system: "Você é um compositor pedagógico infantil. Para o pedido do usuário, gere APENAS um JSON válido com duas chaves: 'lyrics' (letra curta em português brasileiro, de 8 a 20 versos, rimada, alegre, repetitiva, com vocabulário adequado para educação infantil) e 'style' (descrição curta em inglês do estilo musical, ex: 'happy acoustic children song, ukulele, claps, 100 BPM'). Responda somente o JSON.",
|
||||
messages: [{ role: 'user', content: promptText }],
|
||||
temperature: 0.7,
|
||||
max_tokens: 400
|
||||
});
|
||||
const txt = r.text || '';
|
||||
// Tentar extrair JSON mesmo se vier com lixo
|
||||
const m = txt.match(/\{[\s\S]*\}/);
|
||||
if (m) {
|
||||
const parsed = JSON.parse(m[0]);
|
||||
lyrics = parsed.lyrics || '';
|
||||
stylePrompt = parsed.style || '';
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Smart Router] Falha ao gerar letra/estilo via M3:', err.message);
|
||||
}
|
||||
|
||||
// Fallback simples se M3 não produziu
|
||||
if (!lyrics) {
|
||||
lyrics = `Vamos brincar e aprender,\nCom alegria vamos cantar,\n${promptText}\nÉ divertido estudar!`;
|
||||
}
|
||||
if (!stylePrompt) {
|
||||
stylePrompt = 'happy acoustic children song, ukulele, claps, 100 BPM';
|
||||
}
|
||||
|
||||
res.write(`data: ${JSON.stringify({ content: `🎵 *Compondo sua cantiga...* \n\n${lyrics}\n\n` })}\n\n`);
|
||||
|
||||
// 1) Gerar música via endpoint nativo MiniMax music-2.0
|
||||
const minmBase = (process.env.MINIMAX_API_BASE || 'https://api.minimax.io/v1').replace(/\/v1\/?$/, '');
|
||||
const musicResp = await fetch(`${minmBase}/v1/music_generation`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${process.env.OPENROUTER_API_KEY}`,
|
||||
'HTTP-Referer': 'https://camila.reifonas.cloud',
|
||||
'X-Title': 'Camila ChatGPT replica',
|
||||
'Authorization': `Bearer ${process.env.MINIMAX_API_KEY}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'google/lyria-3-clip-preview',
|
||||
messages: [
|
||||
{ role: 'user', content: promptText }
|
||||
],
|
||||
modalities: ['audio'],
|
||||
stream: true
|
||||
model: 'music-2.0',
|
||||
prompt: stylePrompt,
|
||||
lyrics: lyrics,
|
||||
audio_setting: { sample_rate: 32000, bitrate: 128000, format: 'mp3' }
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Erro na API do OpenRouter (Lyria): ${errorText}`);
|
||||
if (!musicResp.ok) {
|
||||
const errText = await musicResp.text();
|
||||
throw new Error(`MiniMax music-2.0 respondeu erro: ${errText}`);
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
const musicData = await musicResp.json();
|
||||
// O endpoint pode retornar audio em base64 dentro de data.audio
|
||||
const audioHex = musicData.data?.audio;
|
||||
if (!audioHex) {
|
||||
throw new Error('Nenhum dado de áudio retornado pela MiniMax music-2.0');
|
||||
}
|
||||
|
||||
// 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
|
||||
// 2) Salvar arquivo local
|
||||
const fs = require('fs');
|
||||
const mediaDir = path.join(__dirname, 'public', 'generated-media');
|
||||
if (!fs.existsSync(mediaDir)) {
|
||||
@@ -2145,26 +2184,16 @@ app.post('/api/chat', requireAuth, async (req, res) => {
|
||||
}
|
||||
const fileName = `musica_${Date.now()}.mp3`;
|
||||
const filePath = path.join(mediaDir, fileName);
|
||||
fs.writeFileSync(filePath, Buffer.from(fullAudioBase64, 'base64'));
|
||||
|
||||
fs.writeFileSync(filePath, Buffer.from(audioHex, 'hex'));
|
||||
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.');
|
||||
}
|
||||
}
|
||||
console.log(`[Smart Router] Música salva em: ${audioUrl}`);
|
||||
|
||||
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 resposta de áudio com Lyria:', error);
|
||||
console.error('Erro na geração de música:', 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();
|
||||
@@ -2366,8 +2395,58 @@ app.post('/api/chat', requireAuth, async (req, res) => {
|
||||
|
||||
try {
|
||||
let chatResponse;
|
||||
let providerUsed = 'OpenRouter';
|
||||
let providerUsed = 'MiniMax-M3';
|
||||
|
||||
// 1) Tentar MiniMax M3 (NÃO-STREAMING para o M3 porque o M3 devolve thinking blocks
|
||||
// que não seguem o protocolo SSE do OpenAI; a UI consome o JSON final e faz o streaming
|
||||
// via o endpoint /v1/chat/completions que vamos montar localmente).
|
||||
let useMinimaxNonStream = false;
|
||||
try {
|
||||
console.log('[Smart Router] Chamando MiniMax M3 (não-streaming, JSON final)...');
|
||||
const minmBase = (process.env.MINIMAX_API_BASE || 'https://api.minimax.io/v1').replace(/\/v1\/?$/, '');
|
||||
const minmResp = await fetch(`${minmBase}/anthropic/v1/messages`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-Api-Key': process.env.MINIMAX_API_KEY,
|
||||
'Content-Type': 'application/json',
|
||||
'anthropic-version': '2023-06-01'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: process.env.MINIMAX_MODEL || 'MiniMax-M3',
|
||||
max_tokens: 4096,
|
||||
temperature,
|
||||
system: enrichedMessages[0].content,
|
||||
messages: enrichedMessages.slice(1)
|
||||
})
|
||||
});
|
||||
if (minmResp.ok) {
|
||||
const data = await minmResp.json();
|
||||
const text = (data.content || []).filter(b => b.type === 'text').map(b => b.text).join('').trim();
|
||||
if (text) {
|
||||
// "Transformar" resposta JSON em um objeto compatível com o resto do fluxo:
|
||||
// devolvemos um Response falso com stream SSE
|
||||
const sseBody =
|
||||
`data: ${JSON.stringify({ choices: [{ delta: { content: text } }] })}\n\n` +
|
||||
`data: [DONE]\n\n`;
|
||||
chatResponse = new Response(sseBody, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/event-stream' }
|
||||
});
|
||||
useMinimaxNonStream = true;
|
||||
} else {
|
||||
console.warn('[Smart Router] M3 respondeu OK mas sem texto; caindo no OpenRouter.');
|
||||
}
|
||||
} else {
|
||||
console.warn('[Smart Router] M3 status', minmResp.status, '- caindo no OpenRouter.');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Erro na conexão com MiniMax M3, tentando OpenRouter...', e.message);
|
||||
}
|
||||
|
||||
// 2) Fallback: OpenRouter (streaming SSE, igual ao original)
|
||||
if ((!chatResponse || !chatResponse.ok) && !useMinimaxNonStream && process.env.OPENROUTER_API_KEY) {
|
||||
console.log('[Smart Router] Usando OpenRouter como fallback de streaming de texto...');
|
||||
providerUsed = 'OpenRouter';
|
||||
try {
|
||||
chatResponse = await fetch('https://openrouter.ai/api/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
@@ -2387,6 +2466,7 @@ app.post('/api/chat', requireAuth, async (req, res) => {
|
||||
} 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) {
|
||||
@@ -2434,26 +2514,27 @@ app.post('/api/chat', requireAuth, async (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Se o OpenRouter, Groq e Google Gemini falharem ou retornarem erro, caímos no fallback do MiniMax M2.7
|
||||
// Se o OpenRouter, Groq e Google Gemini falharem ou retornarem erro, caímos no fallback do MiniMax M3 (streaming)
|
||||
if ((!chatResponse || !chatResponse.ok) && process.env.MINIMAX_API_KEY) {
|
||||
console.log('[Smart Router] Usando MiniMax-M2.7 como fallback de streaming de texto...');
|
||||
providerUsed = 'MiniMax M2.7';
|
||||
console.log('[Smart Router] Usando MiniMax-M3 (streaming OpenAI-compat) como último fallback...');
|
||||
providerUsed = 'MiniMax-M3';
|
||||
try {
|
||||
chatResponse = await fetch('https://api.minimaxi.chat/v1/chat/completions', {
|
||||
const minmBase = (process.env.MINIMAX_API_BASE || 'https://api.minimax.io/v1').replace(/\/v1\/?$/, '');
|
||||
chatResponse = await fetch(`${minmBase}/v1/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${process.env.MINIMAX_API_KEY}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'MiniMax-M2.7',
|
||||
model: process.env.MINIMAX_MODEL || 'MiniMax-M3',
|
||||
messages: enrichedMessages,
|
||||
stream: true,
|
||||
temperature
|
||||
})
|
||||
});
|
||||
} catch (minimaxErr) {
|
||||
console.error('[Smart Router] Erro crítico: Falha no fallback do MiniMax M2.7 também:', minimaxErr.message);
|
||||
console.error('[Smart Router] Erro crítico: Falha no fallback do MiniMax M3 também:', minimaxErr.message);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user