diff --git a/manager/components/Exams.tsx b/manager/components/Exams.tsx index 4221f82..33e2e1f 100644 --- a/manager/components/Exams.tsx +++ b/manager/components/Exams.tsx @@ -26,11 +26,44 @@ const Exams: React.FC = ({ data, updateData }) => { geminiKey: data.aiConfig?.geminiKey ? '********' : '', openaiKey: data.aiConfig?.openaiKey ? '********' : '', claudeKey: data.aiConfig?.claudeKey ? '********' : '', + openrouterKey: data.aiConfig?.openrouterKey ? '********' : '', geminiModel: data.aiConfig?.geminiModel || 'gemini-1.5-flash', openaiModel: data.aiConfig?.openaiModel || 'gpt-4o-mini', - claudeModel: data.aiConfig?.claudeModel || 'claude-3-5-sonnet-20241022' + claudeModel: data.aiConfig?.claudeModel || 'claude-3-5-sonnet-20241022', + openrouterModel: data.aiConfig?.openrouterModel || 'google/gemini-2.5-flash', + activeImageProvider: data.aiConfig?.activeImageProvider || 'gemini' }); + const [dynamicModels, setDynamicModels] = useState<{ + gemini: any[]; + openai: any[]; + claude: any[]; + openrouter: any[]; + }>({ + gemini: [ + { id: 'gemini-1.5-flash', name: 'gemini-1.5-flash (Mais Rápido & Gratuito)' }, + { id: 'gemini-1.5-pro', name: 'gemini-1.5-pro (Multimodal)' }, + { id: 'gemini-2.0-flash-exp', name: 'gemini-2.0-flash-exp (Experimental)' }, + { id: 'gemini-2.5-flash', name: 'gemini-2.5-flash (Se ativo na sua conta)' } + ], + openai: [ + { id: 'gpt-4o-mini', name: 'gpt-4o-mini (Recomendado)' }, + { id: 'gpt-4o', name: 'gpt-4o (Completo)' }, + { id: 'gpt-3.5-turbo', name: 'gpt-3.5-turbo (Legado)' } + ], + claude: [ + { id: 'claude-3-5-sonnet-20241022', name: 'claude-3-5-sonnet-20241022' }, + { id: 'claude-3-5-haiku-20241022', name: 'claude-3-5-haiku-20241022' } + ], + openrouter: [ + { id: 'google/gemini-2.5-flash', name: 'Gemini 2.5 Flash' }, + { id: 'openai/gpt-4o-mini', name: 'GPT-4o Mini' }, + { id: 'anthropic/claude-3.5-sonnet', name: 'Claude 3.5 Sonnet' } + ] + }); + + const [loadingModels, setLoadingModels] = useState<{[key: string]: boolean}>({}); + const [isGeneratingAI, setIsGeneratingAI] = useState(false); const [aiTopic, setAiTopic] = useState(''); const [aiQuantity, setAiQuantity] = useState(5); @@ -44,22 +77,63 @@ const Exams: React.FC = ({ data, updateData }) => { geminiKey: data.aiConfig?.geminiKey ? '********' : '', openaiKey: data.aiConfig?.openaiKey ? '********' : '', claudeKey: data.aiConfig?.claudeKey ? '********' : '', + openrouterKey: data.aiConfig?.openrouterKey ? '********' : '', geminiModel: data.aiConfig?.geminiModel || 'gemini-1.5-flash', openaiModel: data.aiConfig?.openaiModel || 'gpt-4o-mini', - claudeModel: data.aiConfig?.claudeModel || 'claude-3-5-sonnet-20241022' + claudeModel: data.aiConfig?.claudeModel || 'claude-3-5-sonnet-20241022', + openrouterModel: data.aiConfig?.openrouterModel || 'google/gemini-2.5-flash', + activeImageProvider: data.aiConfig?.activeImageProvider || 'gemini' }); setShowAIConfigModal(true); }; + const handleFetchModels = async (provider: 'gemini' | 'openai' | 'claude' | 'openrouter') => { + setLoadingModels(prev => ({ ...prev, [provider]: true })); + try { + let key = ''; + if (provider === 'gemini') key = aiForm.geminiKey; + else if (provider === 'openai') key = aiForm.openaiKey; + else if (provider === 'claude') key = aiForm.claudeKey; + else if (provider === 'openrouter') key = aiForm.openrouterKey; + + const response = await fetch('/api/ai/list-models', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ provider, apiKey: key }) + }); + + if (!response.ok) { + const err = await response.json(); + throw new Error(err.error || 'Erro ao carregar modelos da API.'); + } + + const { models } = await response.json(); + if (models && models.length > 0) { + setDynamicModels(prev => ({ ...prev, [provider]: models })); + showAlert('Sucesso', `Lista de modelos para ${provider.toUpperCase()} atualizada!`, 'success'); + } else { + throw new Error('Nenhum modelo compatível retornado.'); + } + } catch (e: any) { + console.error(e); + showAlert('Erro ao listar modelos', e.message || 'Falha ao buscar modelos, verifique sua chave de API.', 'error'); + } finally { + setLoadingModels(prev => ({ ...prev, [provider]: false })); + } + }; + const handleSaveAIConfig = async () => { const newConfig = { activeProvider: aiForm.activeProvider, geminiKey: aiForm.geminiKey === '********' ? (data.aiConfig?.geminiKey || '') : aiForm.geminiKey, openaiKey: aiForm.openaiKey === '********' ? (data.aiConfig?.openaiKey || '') : aiForm.openaiKey, claudeKey: aiForm.claudeKey === '********' ? (data.aiConfig?.claudeKey || '') : aiForm.claudeKey, + openrouterKey: aiForm.openrouterKey === '********' ? (data.aiConfig?.openrouterKey || '') : aiForm.openrouterKey, geminiModel: aiForm.geminiModel, openaiModel: aiForm.openaiModel, - claudeModel: aiForm.claudeModel + claudeModel: aiForm.claudeModel, + openrouterModel: aiForm.openrouterModel, + activeImageProvider: aiForm.activeImageProvider }; try { @@ -1310,17 +1384,31 @@ const Exams: React.FC = ({ data, updateData }) => {
-
- - +
+
+ + +
+
+ + +
@@ -1337,16 +1425,35 @@ const Exams: React.FC = ({ data, updateData }) => { />
- +
+ + +
@@ -1366,15 +1473,35 @@ const Exams: React.FC = ({ data, updateData }) => { />
- +
+ + +
@@ -1394,14 +1521,83 @@ const Exams: React.FC = ({ data, updateData }) => { />
- +
+ + +
+
+ + + +
+

OpenRouter

+
+
+ + setAiForm({ ...aiForm, openrouterKey: e.target.value })} + placeholder="Cole a chave (sk-or-...)" + className="w-full px-4 py-2.5 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-indigo-500 focus:outline-none transition-all font-medium text-slate-800 text-xs" + /> +
+
+
+ + +
+
diff --git a/manager/server.selfhosted.js b/manager/server.selfhosted.js index 45a21f1..32fa155 100644 --- a/manager/server.selfhosted.js +++ b/manager/server.selfhosted.js @@ -1289,6 +1289,9 @@ app.post('/api/ai/generate-questions', async (req, res) => { } else if (provider === 'claude') { apiKey = aiConfig.claudeKey; modelName = aiConfig.claudeModel || 'claude-3-5-sonnet-20241022'; + } else if (provider === 'openrouter') { + apiKey = aiConfig.openrouterKey; + modelName = aiConfig.openrouterModel || 'google/gemini-2.5-flash'; } if (!apiKey) { @@ -1356,6 +1359,64 @@ Exemplo de formato esperado: const questions = parsed.questions || parsed || []; return res.json({ questions }); + } else if (provider === 'openrouter') { + const payload = { + model: modelName, + messages: [ + { + role: 'system', + content: `Você é um gerador de questões escolares de múltipla escolha para provas de alunos. +Retorne um objeto JSON contendo uma lista de questões na chave "questions". +Cada questão deve conter: +- "text": o enunciado da questão. +- "options": um array com exatamente 4 opções de resposta. +- "correctOptionIndex": o índice da opção correta (de 0 a 3). + +Exemplo de formato esperado: +{ + "questions": [ + { + "text": "Qual é a principal função do processador?", + "options": ["Processar dados", "Armazenar arquivos", "Exibir imagens", "Conectar à internet"], + "correctOptionIndex": 0 + } + ] +}` + }, + { + role: 'user', + content: userContent + } + ], + response_format: { type: 'json_object' } + }; + + const response = await fetch('https://openrouter.ai/api/v1/chat/completions', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${apiKey}`, + 'HTTP-Referer': 'https://edumanager.com.br', + 'X-Title': 'EduManager Pro' + }, + body: JSON.stringify(payload) + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`OpenRouter API retornou erro: ${response.status} - ${errorText}`); + } + + const resJson = await response.json(); + const contentText = resJson.choices?.[0]?.message?.content; + if (!contentText) { + throw new Error('Retorno vazio da API do OpenRouter'); + } + + const parsed = JSON.parse(contentText); + const questions = parsed.questions || parsed || []; + return res.json({ questions }); + } else if (provider === 'claude') { const payload = { model: modelName, @@ -1534,19 +1595,21 @@ app.post('/api/ai/generate-image', async (req, res) => { const appData = await getSchoolData(); const aiConfig = appData.aiConfig || {}; + const activeImageProvider = aiConfig.activeImageProvider || (aiConfig.openaiKey ? 'openai' : 'gemini'); + let provider = ''; let apiKey = ''; - if (aiConfig.openaiKey) { + if (activeImageProvider === 'openai') { provider = 'openai'; apiKey = aiConfig.openaiKey; - } else if (aiConfig.geminiKey) { + } else if (activeImageProvider === 'gemini') { provider = 'gemini'; apiKey = aiConfig.geminiKey; } if (!apiKey) { - return res.status(400).json({ error: 'Nenhuma chave de API de IA (OpenAI ou Gemini) cadastrada para geração de imagens.' }); + return res.status(400).json({ error: `Nenhuma chave de API de IA cadastrada para o provedor de imagem selecionado: ${activeImageProvider.toUpperCase()}` }); } let imageUrl = ''; @@ -1661,6 +1724,109 @@ app.post('/api/ai/generate-image', async (req, res) => { } }); +app.post('/api/ai/list-models', async (req, res) => { + const { provider, apiKey } = req.body; + if (!provider) { + return res.status(400).json({ error: 'Provedor não informado' }); + } + + let key = apiKey; + if (!key || key === '********') { + const appData = await getSchoolData(); + const aiConfig = appData.aiConfig || {}; + if (provider === 'gemini') key = aiConfig.geminiKey; + else if (provider === 'openai') key = aiConfig.openaiKey; + else if (provider === 'claude') key = aiConfig.claudeKey; + else if (provider === 'openrouter') key = aiConfig.openrouterKey; + } + + if (!key && provider !== 'openrouter') { + return res.status(400).json({ error: 'Chave de API não configurada nem informada.' }); + } + + try { + if (provider === 'gemini') { + const url = `https://generativelanguage.googleapis.com/v1/models?key=${key}`; + const response = await fetch(url); + if (!response.ok) { + const errText = await response.text(); + throw new Error(`Erro Gemini: ${response.status} - ${errText}`); + } + const json = await response.json(); + const models = (json.models || []) + .filter(m => m.supportedMethods && m.supportedMethods.includes('generateContent')) + .map(m => ({ + id: m.name.replace('models/', ''), + name: m.displayName || m.name + })); + return res.json({ models }); + + } else if (provider === 'openai') { + const url = 'https://api.openai.com/v1/models'; + const response = await fetch(url, { + headers: { 'Authorization': `Bearer ${key}` } + }); + if (!response.ok) { + const errText = await response.text(); + throw new Error(`Erro OpenAI: ${response.status} - ${errText}`); + } + const json = await response.json(); + const models = (json.data || []) + .filter(m => m.id.includes('gpt') || m.id.includes('o1') || m.id.includes('o3')) + .map(m => ({ + id: m.id, + name: m.id + })); + return res.json({ models }); + + } else if (provider === 'claude') { + const url = 'https://api.anthropic.com/v1/models'; + const response = await fetch(url, { + headers: { + 'x-api-key': key, + 'anthropic-version': '2023-06-01' + } + }); + if (!response.ok) { + console.warn('Erro ao listar modelos do Claude, retornando lista padrão curada.'); + const defaultModels = [ + { id: 'claude-3-5-sonnet-20241022', name: 'claude-3-5-sonnet-20241022' }, + { id: 'claude-3-5-haiku-20241022', name: 'claude-3-5-haiku-20241022' }, + { id: 'claude-3-opus-20240229', name: 'claude-3-opus-20240229' } + ]; + return res.json({ models: defaultModels }); + } + const json = await response.json(); + const models = (json.data || []).map(m => ({ + id: m.id, + name: m.display_name || m.id + })); + return res.json({ models }); + + } else if (provider === 'openrouter') { + const url = 'https://openrouter.ai/api/v1/models'; + const headers = {}; + if (key) headers['Authorization'] = `Bearer ${key}`; + const response = await fetch(url, { headers }); + if (!response.ok) { + const errText = await response.text(); + throw new Error(`Erro OpenRouter: ${response.status} - ${errText}`); + } + const json = await response.json(); + const models = (json.data || []).map(m => ({ + id: m.id, + name: `${m.name || m.id} (${m.id})` + })); + return res.json({ models }); + } + + res.status(400).json({ error: 'Provedor inválido' }); + } catch (err) { + console.error(`Erro ao listar modelos para ${provider}:`, err); + res.status(500).json({ error: err.message }); + } +}); + // Helper para formatar datas para o JSON const formatDateForJSON = (val) => { if (!val) return ''; diff --git a/manager/types.ts b/manager/types.ts index 6a0dea1..c3e8cc4 100644 --- a/manager/types.ts +++ b/manager/types.ts @@ -298,13 +298,16 @@ export interface PreMatriculaInscricao { } export interface AIConfig { - activeProvider: 'gemini' | 'openai' | 'claude'; + activeProvider: 'gemini' | 'openai' | 'claude' | 'openrouter'; geminiKey?: string; openaiKey?: string; claudeKey?: string; + openrouterKey?: string; geminiModel?: string; openaiModel?: string; claudeModel?: string; + openrouterModel?: string; + activeImageProvider?: 'gemini' | 'openai'; } export interface SchoolData {