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 = `
+
`;
+ } else if (type === 'áudio') {
+ mediaHtml = `
+ `;
+ } else if (type === 'vídeo') {
+ mediaHtml = `
+ `;
+ }
+ // ... 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 = `
+
+ `;
+ scrollToBottom();
+ } else if (parsed.type === 'image') {
+ isMediaResponse = true;
+ assistantContent = `
+
+ `;
+ botContentDiv.innerHTML = assistantContent;
+ scrollToBottom();
+ } else if (parsed.type === 'audio') {
+ isMediaResponse = true;
+ const audioId = 'audio_' + Date.now();
+ assistantContent = `
+
+ `;
+ botContentDiv.innerHTML = assistantContent;
+ scrollToBottom();
+ } else if (parsed.type === 'video') {
+ isMediaResponse = true;
+ assistantContent = `
+
+ `;
+ 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 {