feat: implementa geracao de musica com google/lyria-3-clip-preview via OpenRouter
This commit is contained in:
+83
-5
@@ -832,7 +832,7 @@ const initApp = () => {
|
||||
conversationList.innerHTML = '';
|
||||
|
||||
chat.messages.forEach(msg => {
|
||||
appendMessageUI(msg.role, msg.content);
|
||||
appendMessageUI(msg.role, msg.content, msg.media);
|
||||
});
|
||||
|
||||
scrollToBottom();
|
||||
@@ -872,7 +872,7 @@ const initApp = () => {
|
||||
// FLUXO DO CHAT & RENDERIZAÇÃO
|
||||
// ==========================================================================
|
||||
|
||||
const appendMessageUI = (role, content) => {
|
||||
const appendMessageUI = (role, content, media = null) => {
|
||||
const isUser = role === 'user';
|
||||
const row = document.createElement('div');
|
||||
row.className = `message-row ${role}`;
|
||||
@@ -885,12 +885,86 @@ const initApp = () => {
|
||||
if (isUser) {
|
||||
contentHtml = `<p>${escapeHtml(content).replace(/\n/g, '<br>')}</p>`;
|
||||
} else {
|
||||
contentHtml = marked.parse(content);
|
||||
if (media) {
|
||||
if (media.type === 'image') {
|
||||
contentHtml = `
|
||||
<div class="media-message-card">
|
||||
<div class="media-img-container">
|
||||
<img src="${media.url}" alt="Ilustração gerada" class="generated-image" onload="scrollToBottom()">
|
||||
</div>
|
||||
<p class="media-caption">✨ "${escapeHtml(media.originalPrompt)}"</p>
|
||||
<div class="media-actions-row">
|
||||
<a href="${media.url}" download="kemily_ilustracao.png" target="_blank" class="media-download-btn">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
|
||||
</svg>
|
||||
<span>Salvar na Galeria</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
} else if (media.type === 'audio') {
|
||||
const audioId = 'audio_' + Date.now() + '_' + Math.floor(Math.random() * 10000);
|
||||
contentHtml = `
|
||||
<div class="media-message-card">
|
||||
<div class="custom-audio-player">
|
||||
<audio src="${media.url}" id="${audioId}"></audio>
|
||||
<button class="audio-play-btn" onclick="toggleCustomAudio(this)" title="Ouvir música">
|
||||
<svg class="play-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
<svg class="pause-icon hidden" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="audio-timeline-container">
|
||||
<div class="audio-timeline">
|
||||
<div class="audio-progress"></div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="audio-time">0:00</span>
|
||||
</div>
|
||||
<p class="media-caption">🎵 "${escapeHtml(media.originalPrompt)}"</p>
|
||||
<div class="media-actions-row">
|
||||
<a href="${media.url}" download="kemily_musica.mp3" target="_blank" class="media-download-btn">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
|
||||
</svg>
|
||||
<span>Baixar Música</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
} else if (media.type === 'video') {
|
||||
contentHtml = `
|
||||
<div class="media-message-card">
|
||||
<div class="media-video-container">
|
||||
<video controls src="${media.url}" class="generated-video" preload="metadata"></video>
|
||||
</div>
|
||||
<p class="media-caption">🎥 "${escapeHtml(media.originalPrompt)}"</p>
|
||||
<div class="media-actions-row">
|
||||
<a href="${media.url}" download="kemily_video.mp4" target="_blank" class="media-download-btn">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
|
||||
</svg>
|
||||
<span>Salvar Vídeo</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
} else {
|
||||
if (content.trim().startsWith('<div class="media-message-card">')) {
|
||||
contentHtml = content;
|
||||
} else {
|
||||
contentHtml = marked.parse(content);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const actionsHtml = !isUser ? `
|
||||
<div class="message-actions">
|
||||
<button class="msg-action-btn btn-copy-msg" title="Copiar resposta" data-content="${escapeHtml(content).replace(/"/g, '"')}">
|
||||
<button class="msg-action-btn btn-copy-msg" title="Copiar resposta" data-content="${media ? escapeHtml(content) : escapeHtml(content).replace(/"/g, '"')}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 17.25v3.375c0 .621-.504 1.125-1.125 1.125h-9.75a1.125 1.125 0 0 1-1.125-1.125V7.875c0-.621.504-1.125 1.125-1.125H6.75a9.06 9.06 0 0 1 1.5.124m7.5 10.376h3.375c.621 0 1.125-.504 1.125-1.125V11.25c0-4.46-3.243-8.161-7.5-8.876a9.06 9.06 0 0 0-1.5-.124H9.375c-.621 0-1.125.504-1.125 1.125v3.5m7.5 10.375H9.375a1.125 1.125 0 0 1-1.125-1.125v-9.25m12 6.625v-1.875a3.375 3.375 0 0 0-3.375-3.375h-1.5a1.125 1.125 0 0 1-1.125-1.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H9.75" />
|
||||
</svg>
|
||||
@@ -902,7 +976,7 @@ const initApp = () => {
|
||||
</svg>
|
||||
<span>Regenerar</span>
|
||||
</button>
|
||||
<button class="msg-action-btn btn-speak" title="Ouvir resposta" data-content="${escapeHtml(content).replace(/"/g, '"')}">
|
||||
<button class="msg-action-btn btn-speak" title="Ouvir resposta" data-content="${media ? escapeHtml(content) : escapeHtml(content).replace(/"/g, '"')}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19.114 5.636a9 9 0 0 1 0 12.728M16.463 8.288a5.25 5.25 0 0 1 0 7.424M6.75 8.25l4.72-4.72a.75.75 0 0 1 1.28.53v15.88a.75.75 0 0 1-1.28.53l-4.72-4.72H4.51c-.88 0-1.704-.507-1.938-1.354A9.009 9.009 0 0 1 2.25 12c0-.83.112-1.633.322-2.396C2.806 8.756 3.63 8.25 4.51 8.25H6.75Z" />
|
||||
</svg>
|
||||
@@ -1192,6 +1266,7 @@ const initApp = () => {
|
||||
const decoder = new TextDecoder('utf-8');
|
||||
let buffer = '';
|
||||
let isMediaResponse = false;
|
||||
let mediaMsgData = null;
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
@@ -1235,6 +1310,7 @@ const initApp = () => {
|
||||
scrollToBottom();
|
||||
} else if (parsed.type === 'image') {
|
||||
isMediaResponse = true;
|
||||
mediaMsgData = { type: 'image', url: parsed.url, originalPrompt: parsed.originalPrompt };
|
||||
assistantContent = `
|
||||
<div class="media-message-card">
|
||||
<div class="media-img-container">
|
||||
@@ -1255,6 +1331,7 @@ const initApp = () => {
|
||||
scrollToBottom();
|
||||
} else if (parsed.type === 'audio') {
|
||||
isMediaResponse = true;
|
||||
mediaMsgData = { type: 'audio', url: parsed.url, originalPrompt: parsed.originalPrompt };
|
||||
const audioId = 'audio_' + Date.now();
|
||||
assistantContent = `
|
||||
<div class="media-message-card">
|
||||
@@ -1290,6 +1367,7 @@ const initApp = () => {
|
||||
scrollToBottom();
|
||||
} else if (parsed.type === 'video') {
|
||||
isMediaResponse = true;
|
||||
mediaMsgData = { type: 'video', url: parsed.url, originalPrompt: parsed.originalPrompt };
|
||||
assistantContent = `
|
||||
<div class="media-message-card">
|
||||
<div class="media-video-container">
|
||||
|
||||
@@ -270,7 +270,7 @@ app.post('/api/chat', requireAuth, async (req, res) => {
|
||||
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) {
|
||||
if (!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.";
|
||||
|
||||
@@ -432,21 +432,127 @@ 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)
|
||||
// ==========================================================================
|
||||
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: 'A geração de músicas no momento está indisponível...' })}\n\n`);
|
||||
res.write(`data: ${JSON.stringify({ type: 'media_status', message: 'Compondo e gerando música pedagógica com Lyria AI...' })}\n\n`);
|
||||
|
||||
try {
|
||||
const message = "A geração de músicas no momento está indisponível devido às limitações do plano. No entanto, se você quiser, eu posso compor uma linda letra de música infantil ou poesia pedagógica sobre o assunto para você! É só me dizer sobre o que quer falar.";
|
||||
res.write(`data: ${JSON.stringify({ content: message })}\n\n`);
|
||||
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('');
|
||||
const audioUrl = `data:audio/mp3;base64,${fullAudioBase64}`;
|
||||
// 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:', error);
|
||||
res.write(`data: ${JSON.stringify({ content: `Ocorreu um erro ao compor a música: ${error.message}` })}\n\n`);
|
||||
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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user