From 9c2ec032b14c3b7e55cf9e0fefae11dc93a97422 Mon Sep 17 00:00:00 2001 From: admtracksteel Date: Tue, 26 May 2026 17:05:25 +0000 Subject: [PATCH] feat: implementado Smart Router Multimodal via MiniMax e OpenRouter --- .env | 2 + PLANO_SMART_ROUTER_MULTIMODAL.md | 491 +++++++++++++++++++++++++++++++ public/app.js | 149 +++++++++- public/style.css | 205 +++++++++++++ server.js | 341 ++++++++++++++++++++- 5 files changed, 1179 insertions(+), 9 deletions(-) create mode 100644 PLANO_SMART_ROUTER_MULTIMODAL.md diff --git a/.env b/.env index 98b63e6..558f6c1 100644 --- a/.env +++ b/.env @@ -3,3 +3,5 @@ ACCESS_PASSWORD=puglindo26 OPENROUTER_API_KEY=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 OPENROUTER_MODEL=openai/gpt-4.1-nano SESSION_SECRET=camila-secret-key-321123 +MINIMAX_API_KEY=sk-cp-iWzSsdzjVy0tT5dnvv_sGrAlv3Nw7kkrZ8hPK0KjbNeSRQxIY1O6AhkQAq_yrYV1XPJY1TH6ZQeOlJZd-Zi_elkJhef9PcTSzn2m9bcKIxezarM19BREcVM +MINIMAX_API_BASE=https://api.minimax.io/v1 diff --git a/PLANO_SMART_ROUTER_MULTIMODAL.md b/PLANO_SMART_ROUTER_MULTIMODAL.md new file mode 100644 index 0000000..15021ad --- /dev/null +++ b/PLANO_SMART_ROUTER_MULTIMODAL.md @@ -0,0 +1,491 @@ +# Plano Técnico — Smart Router Multimodal para Camila AI + +**App:** `/root/Apps/Camila` +**Data:** 2026-05-26 +**Objetivo:** Roteamento automático de LLMs (OpenRouter + MiniMax) baseado na intenção do prompt da Camila + +--- + +## 1. Arquitetura Atual (Linha Base) + +``` +Camila → frontend (app.js) → POST /api/chat → OpenRouter (GPT-4.1-nano) → SSE stream → frontend +``` + +**Arquivos relevantes:** +- `server.js` — Express, 362 linhas, rotas: `/api/chat`, `/api/login`, `/api/logout`, `/api/status` +- `.env` — `OPENROUTER_API_KEY`, `OPENROUTER_MODEL`, `ACCESS_PASSWORD` +- `public/app.js` — SPA, streaming SSE, renderiza markdown +- `public/index.html` — UI principal + +**Dependências atuais:** `express`, `cookie-parser`, `dotenv`, `msedge-tts` + +--- + +## 2. Arquitetura Proposta (Smart Router) + +``` +Camila digita prompt + │ + ▼ +┌───────────────────┐ +│ INTENT DETECTOR │ ← analisa prompt → classifica tipo +│ (app.js/frontend)│ +└────────┬──────────┘ + │ + ┌────┴──────────────────────┐ + │ Classificação do Prompt │ + ├──────────────────────────┤ + │ [texto] → OpenRouter │ + │ [imagem] → MiniMax │ + │ [áudio/música]→ MiniMax │ + │ [vídeo] → MiniMax │ + │ [análise img]→ MiniMax │ + └───────────────────────────┘ + │ + ┌────┴────────┐ + │ │ + ▼ ▼ + OpenRouter MiniMax API + (SSE stream) (sync/async) + │ │ + └──────┬──────┘ + ▼ + Response Unificada + (mesmo visual no chat) +``` + +--- + +## 3. Detalhamento por Etapa + +### 3.1 Intent Detector (Frontend — `app.js`) + +**O que faz:** Analisa o prompt da Camila antes de enviar e classifica em uma intenção. + +**Keywords de classificação:** + +| Tipo | Keywords (pt-BR) | Provider | +|------|-----------------|----------| +| `texto` | (nenhuma das abaixo) | OpenRouter | +| `imagem_gerar` | "gere uma imagem", "crie uma imagem", "desenhe", "gere figure", "crie figura", "imagem de", "foto de" | MiniMax Image | +| `áudio/música` | "gere uma música", "crie uma música", "cante", "melodia", "som para", "trilha" | MiniMax Audio | +| `vídeo` | "gere um vídeo", "crie um vídeo", "video de", "animação de" | MiniMax Video | +| `análise_img` | (imagem anexada + pergunta sobre ela) | MiniMax Vision | + +**Regex simplificado (implementação):** + +```javascript +const detectIntent = (text, hasAttachedImages) => { + const lower = text.toLowerCase(); + + if (hasAttachedImages) return 'análise_img'; + + if (/gere uma imagem|crie uma imagem|desenhe|gere figura|crie figura|imagem de|foto de/i.test(lower)) + return 'imagem_gerar'; + + if (/gere uma música|crie uma música|melodia|trilha|som para|cante/i.test(lower)) + return 'áudio'; + + if (/gere um vídeo|crie um vídeo|video de|animação de/i.test(lower)) + return 'vídeo'; + + return 'texto'; +}; +``` + +**Prompt Rewriting (para geração de mídia):** + +Quando o tipo ≠ `texto`, o app reescreve o pedido para o formato otimizado do MiniMax: + +```javascript +const rewritePrompt = (text, intent) => { + // Para imagem: enrich com detalhes visuais + if (intent === 'imagem_gerar') { + return ` Gere uma imagem: ${text}. Estilo: ilustração infantil educativo, cores vibrantes, pedagogico.`; + } + // Para música: enrich com estilo + if (intent === 'audio') { + return `Crie uma música: ${text}. Estilo: infantil, educativo, cantado por voz feminina.`; + } + return text; +}; +``` + +--- + +### 3.2 Backend — Novas Rotas (`server.js`) + +**3.2.1 Nova rota: `/api/generate` (unificada)** + +Substitui ou complementa `/api/chat`. Aceita campo `type` adicional. + +```javascript +// .env precisa adicionar: +MINIMAX_API_KEY=seu_token_minimax +MINIMAX_API_BASE=https://api.minimax.chat/v1 // confirmar endpoint real +``` + +**Rotas novas:** + +| Endpoint | Método | Provider | Tipo Retorno | +|----------|--------|----------|--------------| +| `/api/chat` | POST | OpenRouter | SSE (mantém) | +| `/api/image` | POST | MiniMax Image | JSON `{url, prompt}` | +| `/api/audio` | POST | MiniMax Audio/T2A | JSON `{url, duration}` | +| `/api/video` | POST | MiniMax Video | JSON `{url, duration, status}` | +| `/api/analyze-image` | POST | MiniMax Vision | SSE (como chat) | + +**Exemplo de implementação — `/api/image`:** + +```javascript +app.post('/api/image', requireAuth, async (req, res) => { + const { prompt, originalPrompt } = req.body; + + try { + const response = await fetch(`${process.env.MINIMAX_API_BASE}/images/generations`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${process.env.MINIMAX_API_KEY}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + model: 'minimax-image-01', + prompt: prompt, + num_images: 1, + width: 1024, + height: 1024 + }) + }); + + const data = await response.json(); + + if (!response.ok) { + return res.status(response.status).json({ error: 'Erro ao gerar imagem' }); + } + + // Formato de resposta padronizado + res.json({ + type: 'image', + url: data.data?.[0]?.url, + prompt: originalPrompt, + provider: 'minimax' + }); + + } catch (error) { + console.error('Erro /api/image:', error); + res.status(500).json({ error: 'Erro interno ao gerar imagem' }); + } +}); +``` + +**Exemplo de implementação — `/api/audio` (text-to-audio/MiniMax T2A):** + +```javascript +app.post('/api/audio', requireAuth, async (req, res) => { + const { text, voice } = req.body; + + try { + const response = await fetch(`${process.env.MINIMAX_API_BASE}/t2a_v2`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${process.env.MINIMAX_API_KEY}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + model: 'speech-01', + text: text, + voice_setting: { + voice_id: voice || 'female-pt-BR-001' + }, + output_format: 'mp3' + }) + }); + + // Audio возвращается como base64 ou URL + const data = await response.json(); + res.json({ + type: 'audio', + url: data.data?.audio_url, + duration: data.data?.duration, + provider: 'minimax' + }); + + } catch (error) { + console.error('Erro /api/audio:', error); + res.status(500).json({ error: 'Erro interno ao gerar áudio' }); + } +}); +``` + +**Exemplo de implementação — `/api/video` (MiniMax S2V?):** + +```javascript +app.post('/api/video', requireAuth, async (req, res) => { + const { prompt, duration } = req.body; + + // Video geralmente é async (job-based) + // Poll status endpoint até completar + + try { + 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: prompt, + duration: duration || 10 // segundos + }) + }); + + const job = await response.json(); + // Retornar job_id para o frontend trackear + res.json({ + type: 'video', + job_id: job.job_id, + status: 'processing', + provider: 'minimax' + }); + + } catch (error) { + console.error('Erro /api/video:', error); + res.status(500).json({ error: 'Erro interno ao gerar vídeo' }); + } +}); +``` + +**3.2.2 Status endpoint atualizado (`/api/status`):** + +```javascript +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() + }); +}); +``` + +--- + +### 3.3 Frontend — `app.js` (Modificações) + +**Mudanças no fluxo de `handleSendMessage`:** + +```javascript +const handleSendMessage = async (text, skipUserAppend = false, files = null) => { + // 1. Detectar intenção + const hasImages = attachedFiles.some(f => f.type.startsWith('image/')); + const intent = detectIntent(text, hasImages); + + // 2. Se não é texto, desviar para endpoint específico + if (intent !== 'texto') { + return handleMediaGeneration(text, intent, attachedFiles); + } + + // 3. Fluxo normal de texto (mantém SSE streaming) + // ... (código existente) +}; + +const handleMediaGeneration = async (text, intent, files) => { + // Mostrar estado de loading no chat + const loadingRow = appendMediaLoadingMessage(intent); + + // 3.1 Prompt rewriting + const rewrittenPrompt = rewritePrompt(text, intent); + + // 3.2 Chamar endpoint correto + let endpoint = '/api/image'; + let payload = { prompt: rewrittenPrompt, originalPrompt: text }; + + if (intent === 'áudio') { + endpoint = '/api/audio'; + payload = { text, voice: 'pt-BR-female' }; + } else if (intent === 'vídeo') { + endpoint = '/api/video'; + payload = { prompt: rewrittenPrompt, duration: 10 }; + } + + try { + const response = await fetch(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + + const data = await response.json(); + + // 3.3 Renderizar resposta + replaceLoadingWithMediaMessage(loadingRow, intent, data); + + } catch (error) { + showMediaError(loadingRow, intent); + } +}; +``` + +**UI de mensagem para mídia gerada:** + +```javascript +const appendMediaMessage = (type, data) => { + // Template unificado + let mediaHtml = ''; + if (type === 'imagem_gerar') { + mediaHtml = ` +
+ Imagem gerada +

${escapeHtml(data.originalPrompt)}

+
`; + } else if (type === 'áudio') { + mediaHtml = ` +
+ +

${escapeHtml(data.originalPrompt)}

+
`; + } else if (type === 'vídeo') { + mediaHtml = ` +
+ +

${escapeHtml(data.originalPrompt)}

+
`; + } + // ... injetar no DOM +}; +``` + +**CSS novo para mensagens de mídia** (adicionar em `style.css`): + +```css +.media-message { + margin: 12px 0; + border-radius: 12px; + overflow: hidden; +} + +.media-message img.generated-image { + max-width: 100%; + border-radius: 12px; + border: 1px solid var(--border-light); +} + +.media-message video, +.media-message audio { + max-width: 100%; + border-radius: 12px; +} + +.media-caption { + font-size: 13px; + color: var(--text-muted); + margin-top: 8px !important; + font-style: italic; +} +``` + +--- + +### 4. `.env` — Variáveis Novas + +```bash +# Existentes +PORT=3000 +ACCESS_PASSWORD=puglindo26 +OPENROUTER_API_KEY=sk-or-...13d0 +OPENROUTER_MODEL=openai/gpt-4.1-nano +SESSION_SECRET=camila-secret-key-321123 + +# Novas +MINIMAX_API_KEY=seu_token_minimax_aqui +MINIMAX_API_BASE=https://api.minimax.chat/v1 +``` + +--- + +### 5. Ordem de Implementação Sugerida + +``` +FASE 1 — Backend mínimo (1-2h) + ├── Adicionar MINIMAX_API_KEY ao .env + ├── Criar rota /api/image (geração de imagem) + ├── Criar rota /api/audio (text-to-audio) + ├── Atualizar /api/status com flag minimax_available + └── Testar rotas com curl + +FASE 2 — Frontend mínimo (1-2h) + ├── Implementar detectIntent() em app.js + ├── Implementar handleMediaGeneration() + ├── Criar template de mensagem de imagem no chat + ├── Adicionar CSS para .media-message + └── Testar fluxo completo: digitar → gerar → ver imagem + +FASE 3 — Geração de música/vídeo (2-3h) + ├── Criar /api/audio (se MiniMax tem TTS/Música) + ├── Criar /api/video (se MiniMax suporta) + ├── Prompt rewriting refinado + └── UI para áudio/vídeo (player nativo HTML5) + +FASE 4 — Análise de imagem (vision) (2h) + ├── Detectar imagem anexada + pergunta + ├── /api/analyze-image → MiniMax Vision + ├── Streaming SSE da análise como texto + └── Kemily "enxerga" a imagem e comenta + +FASE 5 — Polish (1-2h) + ├── Loading states animados para mídia + ├── Error handling robusto + ├── Fallback: se MiniMax falhar, pedir ao GPT que descreva o que geraria + └── Documentação do sistema +``` + +**Tempo total estimado:** 8-12 horas (distribuído) + +--- + +### 6. Checklist de Verificação + +- [ ] `MINIMAX_API_KEY` adicionada ao `.env` +- [ ] Intent detector funcionando (regex testado) +- [ ] `/api/image` responde com URL de imagem +- [ ] `/api/audio` responde com URL de áudio (se disponível) +- [ ] `/api/video` responde com URL de vídeo ou job_id (se disponível) +- [ ] Frontend exibe imagem gerada no chat +- [ ] Frontend exibe player de áudio/vídeo +- [ ] Loading state visível durante geração +- [ ] Error state graceful se provider falhar +- [ ] Kemily responde em texto quando mídia é gerada ("Gerei essa imagem para você!") + +--- + +### 7. Riscos e Mitigações + +| Risco | Probabilidade | Mitigação | +|-------|--------------|-----------| +| MiniMax API não tem endpoint de música | Média | Usar só imagem/vídeo; música como "将来" | +| CORS errors no frontend | Baixa | Backend faz proxy, nunca chama API direto | +| Imagem gerada tarde (async) | Média | Polling com loading spinner + status | +| Prompt rewriting gera prompt ruim | Média | Manter simples; GPT-4.1-nano reescreve melhor | +| Custo miniMax exceder límite | Baixa | Rate limit por usuário no backend | + +--- + +### 8. Referências da API MiniMax + +**Precisa confirmar endpoints oficiais.** Até a implementação, consultar: + +- Portal desenvolvedor: https://www.minimaxi.com/developer +- Dashboard da conta MiniMax para ver módulos ativos (geração de imagem, áudio, vídeo) +- A API key atual do Marcos está no painel MiniMax dele + +**Headers comuns MiniMax:** +``` +Authorization: Bearer {MINIMAX_API_KEY} +Content-Type: application/json +``` + +--- + +*Plano gerado por Hermes — 2026-05-26* \ No newline at end of file diff --git a/public/app.js b/public/app.js index 2f0612e..0027a69 100644 --- a/public/app.js +++ b/public/app.js @@ -1191,6 +1191,7 @@ const initApp = () => { const reader = response.body.getReader(); const decoder = new TextDecoder('utf-8'); let buffer = ''; + let isMediaResponse = false; while (true) { const { done, value } = await reader.read(); @@ -1211,6 +1212,7 @@ const initApp = () => { } try { const parsed = JSON.parse(dataContent); + if (parsed.content) { assistantContent += parsed.content; // Renderizar markdown em progresso (com o cursor no final) @@ -1221,6 +1223,90 @@ const initApp = () => { hljs.highlightElement(block); }); + scrollToBottom(); + } else if (parsed.type === 'media_status') { + isMediaResponse = true; + botContentDiv.innerHTML = ` +
+
+

${escapeHtml(parsed.message)}

+
+ `; + scrollToBottom(); + } else if (parsed.type === 'image') { + isMediaResponse = true; + assistantContent = ` +
+
+ Ilustração gerada +
+

✨ "${escapeHtml(parsed.originalPrompt)}"

+ +
+ `; + botContentDiv.innerHTML = assistantContent; + scrollToBottom(); + } else if (parsed.type === 'audio') { + isMediaResponse = true; + const audioId = 'audio_' + Date.now(); + assistantContent = ` +
+
+ + +
+
+
+
+
+ 0:00 +
+

🎵 "${escapeHtml(parsed.originalPrompt)}"

+ +
+ `; + botContentDiv.innerHTML = assistantContent; + scrollToBottom(); + } else if (parsed.type === 'video') { + isMediaResponse = true; + assistantContent = ` +
+
+ +
+

🎥 "${escapeHtml(parsed.originalPrompt)}"

+ +
+ `; + botContentDiv.innerHTML = assistantContent; scrollToBottom(); } } catch (err) { @@ -1230,15 +1316,17 @@ const initApp = () => { } } - // Remover o cursor de digitação no final + // Remover o cursor de digitação no final se não for mensagem de mídia const cursor = botContentDiv.querySelector('.typing-cursor'); if (cursor) cursor.remove(); - // Parse final limpo do markdown - botContentDiv.innerHTML = marked.parse(assistantContent); - botContentDiv.querySelectorAll('pre code').forEach((block) => { - hljs.highlightElement(block); - }); + // Parse final limpo do markdown (se for texto normal) + if (!isMediaResponse) { + botContentDiv.innerHTML = marked.parse(assistantContent); + botContentDiv.querySelectorAll('pre code').forEach((block) => { + hljs.highlightElement(block); + }); + } // Adicionar botões de ação (Copiar, Regenerar, Ouvir) após o streaming const escapedContent = escapeHtml(assistantContent).replace(/"/g, '"'); @@ -1371,6 +1459,55 @@ const initApp = () => { initVoiceRecognition(); }; +// Player de áudio personalizado global para as mídias geradas +window.toggleCustomAudio = (btn) => { + const player = btn.closest('.custom-audio-player'); + const audio = player.querySelector('audio'); + const playIcon = btn.querySelector('.play-icon'); + const pauseIcon = btn.querySelector('.pause-icon'); + const progress = player.querySelector('.audio-progress'); + const timeSpan = player.querySelector('.audio-time'); + + if (audio.paused) { + // Pausar outros players que possam estar ativos + document.querySelectorAll('.custom-audio-player audio').forEach(a => { + if (a !== audio && !a.paused) { + a.pause(); + const otherBtn = a.closest('.custom-audio-player').querySelector('.audio-play-btn'); + otherBtn.querySelector('.play-icon').classList.remove('hidden'); + otherBtn.querySelector('.pause-icon').classList.add('hidden'); + } + }); + + audio.play(); + playIcon.classList.add('hidden'); + pauseIcon.classList.remove('hidden'); + } else { + audio.pause(); + playIcon.classList.remove('hidden'); + pauseIcon.classList.add('hidden'); + } + + // Atualizar progresso em tempo real + audio.ontimeupdate = () => { + if (audio.duration) { + const percent = (audio.currentTime / audio.duration) * 100; + progress.style.width = percent + '%'; + + const mins = Math.floor(audio.currentTime / 60); + const secs = Math.floor(audio.currentTime % 60).toString().padStart(2, '0'); + timeSpan.textContent = `${mins}:${secs}`; + } + }; + + audio.onended = () => { + playIcon.classList.remove('hidden'); + pauseIcon.classList.add('hidden'); + progress.style.width = '0%'; + timeSpan.textContent = '0:00'; + }; +}; + if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initApp); } else { diff --git a/public/style.css b/public/style.css index 2d73984..8aa3fdf 100644 --- a/public/style.css +++ b/public/style.css @@ -1898,3 +1898,208 @@ code { color: var(--text-muted); line-height: 1.4; } + +/* ========================================================================== + SMART ROUTER MEDIA GENERATION (Imagem, Áudio e Vídeo) + ========================================================================== */ +.media-message-card { + background: var(--bg-secondary); + border: 1px solid var(--border-light); + border-radius: 16px; + padding: 16px; + margin: 12px 0; + max-width: 500px; + display: flex; + flex-direction: column; + gap: 12px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15); + animation: fadeIn 0.3s ease-out; +} + +.media-img-container, .media-video-container { + width: 100%; + border-radius: 12px; + overflow: hidden; + background: rgba(0, 0, 0, 0.2); + display: flex; + justify-content: center; + align-items: center; + aspect-ratio: 1/1; +} + +.media-video-container { + aspect-ratio: 16/9; +} + +.generated-image { + width: 100%; + height: 100%; + object-fit: cover; + border-radius: 12px; + transition: transform var(--transition-normal); +} + +.generated-image:hover { + transform: scale(1.02); +} + +.generated-video { + width: 100%; + height: 100%; + object-fit: contain; + border-radius: 12px; +} + +.media-caption { + font-size: 13px; + color: var(--text-secondary); + font-style: italic; + line-height: 1.4; + margin: 0; + padding: 0 4px; +} + +.media-actions-row { + display: flex; + gap: 8px; + width: 100%; +} + +.media-download-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + background: linear-gradient(135deg, var(--brand-green) 0%, #1e7e4a 100%); + color: #ffffff; + border: none; + border-radius: 10px; + padding: 10px 16px; + font-size: 13px; + font-weight: 600; + text-decoration: none; + cursor: pointer; + transition: opacity var(--transition-fast), transform var(--transition-fast); + flex: 1; + box-shadow: 0 2px 8px rgba(30, 190, 110, 0.2); +} + +.media-download-btn:hover { + opacity: 0.9; + transform: translateY(-1px); +} + +.media-download-btn:active { + transform: translateY(0); +} + +.media-download-btn svg { + width: 16px; + height: 16px; +} + +/* Player de Áudio Customizado */ +.custom-audio-player { + background: var(--bg-tertiary); + border: 1px solid var(--border-light); + border-radius: 12px; + padding: 12px; + display: flex; + align-items: center; + gap: 12px; + width: 100%; +} + +.audio-play-btn { + background: var(--brand-green); + border: none; + color: #ffffff; + width: 36px; + height: 36px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + flex-shrink: 0; + transition: transform var(--transition-fast); +} + +.audio-play-btn:hover { + transform: scale(1.05); +} + +.audio-play-btn svg { + width: 18px; + height: 18px; +} + +.audio-play-btn svg.hidden { + display: none; +} + +.audio-timeline-container { + flex: 1; + height: 6px; + background: rgba(255, 255, 255, 0.1); + border-radius: 3px; + position: relative; + cursor: pointer; +} + +.audio-timeline { + width: 100%; + height: 100%; +} + +.audio-progress { + height: 100%; + background: var(--brand-green); + border-radius: 3px; + width: 0%; + transition: width 0.1s linear; +} + +.audio-time { + font-size: 12px; + color: var(--text-muted); + font-family: monospace; + min-width: 32px; + text-align: right; +} + +/* Loading de Mídia */ +.media-loading { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 24px; + gap: 12px; + text-align: center; +} + +.media-spinner { + width: 32px; + height: 32px; + border: 3px solid rgba(30, 190, 110, 0.1); + border-radius: 50%; + border-top-color: var(--brand-green); + animation: spin 1s ease-in-out infinite; +} + +.media-loading-text { + font-size: 13.5px; + color: var(--text-secondary); + margin: 0; + font-weight: 500; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} diff --git a/server.js b/server.js index 65630ee..49ec7e3 100644 --- a/server.js +++ b/server.js @@ -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 {