feat: adiciona selecao de modelos de IA, geracao de imagens DALL-E/Imagen e suporte a material de apoio no gerador
Build and Deploy (Gitea) / build-and-deploy (push) Successful in 2m4s Details

This commit is contained in:
Sidney 2026-07-17 20:38:36 -03:00
parent 6816c945cc
commit de08900fbe
3 changed files with 401 additions and 75 deletions

View File

@ -25,20 +25,28 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
activeProvider: data.aiConfig?.activeProvider || 'gemini',
geminiKey: data.aiConfig?.geminiKey ? '********' : '',
openaiKey: data.aiConfig?.openaiKey ? '********' : '',
claudeKey: data.aiConfig?.claudeKey ? '********' : ''
claudeKey: data.aiConfig?.claudeKey ? '********' : '',
geminiModel: data.aiConfig?.geminiModel || 'gemini-1.5-flash',
openaiModel: data.aiConfig?.openaiModel || 'gpt-4o-mini',
claudeModel: data.aiConfig?.claudeModel || 'claude-3-5-sonnet-20241022'
});
const [isGeneratingAI, setIsGeneratingAI] = useState(false);
const [aiTopic, setAiTopic] = useState('');
const [aiQuantity, setAiQuantity] = useState(5);
const [aiDifficulty, setAiDifficulty] = useState('medium');
const [aiContextText, setAiContextText] = useState('');
const [isGeneratingImage, setIsGeneratingImage] = useState<{[key: string]: boolean}>({});
const handleOpenAIConfig = () => {
setAiForm({
activeProvider: data.aiConfig?.activeProvider || 'gemini',
geminiKey: data.aiConfig?.geminiKey ? '********' : '',
openaiKey: data.aiConfig?.openaiKey ? '********' : '',
claudeKey: data.aiConfig?.claudeKey ? '********' : ''
claudeKey: data.aiConfig?.claudeKey ? '********' : '',
geminiModel: data.aiConfig?.geminiModel || 'gemini-1.5-flash',
openaiModel: data.aiConfig?.openaiModel || 'gpt-4o-mini',
claudeModel: data.aiConfig?.claudeModel || 'claude-3-5-sonnet-20241022'
});
setShowAIConfigModal(true);
};
@ -48,7 +56,10 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
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
claudeKey: aiForm.claudeKey === '********' ? (data.aiConfig?.claudeKey || '') : aiForm.claudeKey,
geminiModel: aiForm.geminiModel,
openaiModel: aiForm.openaiModel,
claudeModel: aiForm.claudeModel
};
try {
@ -295,7 +306,8 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
body: JSON.stringify({
topic: aiTopic,
quantity: qty,
difficulty: aiDifficulty
difficulty: aiDifficulty,
contextText: aiContextText
})
});
@ -356,7 +368,8 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
body: JSON.stringify({
topic: topic,
quantity: 1,
difficulty: aiDifficulty
difficulty: aiDifficulty,
contextText: aiContextText
})
});
@ -396,6 +409,57 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
}
};
const handleGenerateImageAI = async (qIndex: number) => {
if (!editingExam) return;
const question = editingExam.questions[qIndex];
if (!question.text.trim()) {
showAlert('Atenção', 'Digite o enunciado da questão para gerar a imagem.', 'warning');
return;
}
const activeProvider = data.aiConfig?.activeProvider || 'gemini';
const hasKey = !!(
(activeProvider === 'gemini' && data.aiConfig?.geminiKey) ||
(activeProvider === 'openai' && data.aiConfig?.openaiKey) ||
(activeProvider === 'claude' && data.aiConfig?.claudeKey)
);
if (!hasKey) {
showAlert('IA não configurada', 'Por favor, configure sua chave de API clicando no botão "Configurar IA" no cabeçalho.', 'warning');
return;
}
setIsGeneratingImage(prev => ({ ...prev, [question.id]: true }));
try {
const response = await fetch('/api/ai/generate-image', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt: question.text })
});
if (!response.ok) {
const errData = await response.json();
throw new Error(errData.error || 'Erro ao gerar imagem por IA.');
}
const { url } = await response.json();
if (!url) {
throw new Error('URL da imagem não retornada.');
}
const newQuestions = [...editingExam.questions];
newQuestions[qIndex] = { ...newQuestions[qIndex], imageUrl: url };
setEditingExam({ ...editingExam, questions: newQuestions });
showAlert('Sucesso', 'Imagem gerada por IA e anexada com sucesso!', 'success');
} catch (e: any) {
console.error(e);
showAlert('Erro ao gerar imagem', e.message || 'Houve uma falha de comunicação com o servidor para gerar a imagem.', 'error');
} finally {
setIsGeneratingImage(prev => ({ ...prev, [question.id]: false }));
}
};
const handleAddQuestion = () => {
if (!editingExam) return;
setEditingExam({
@ -723,6 +787,41 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
</div>
</div>
<div className="mt-6 pt-6 border-t border-slate-100">
<div className="flex items-center justify-between mb-2">
<label className="block text-xs font-bold text-slate-500 uppercase tracking-wider">Material de Apoio (Opcional - A IA se baseará apenas neste texto)</label>
<label className="flex items-center gap-1.5 text-xs font-bold text-indigo-600 hover:text-indigo-800 cursor-pointer">
<Upload size={14} />
<span>Carregar arquivo (.txt)</span>
<input
type="file"
accept=".txt"
className="hidden"
onChange={e => {
const file = e.target.files?.[0];
if (!file) return;
if (file.type === 'text/plain' || file.name.endsWith('.txt')) {
const reader = new FileReader();
reader.onload = (evt) => {
setAiContextText(evt?.target?.result as string || '');
showAlert('Sucesso', 'Arquivo de texto lido com sucesso!', 'success');
};
reader.readAsText(file);
} else {
showAlert('Formato inválido', 'Por favor, selecione um arquivo de texto (.txt).', 'warning');
}
}}
/>
</label>
</div>
<textarea
value={aiContextText}
onChange={e => setAiContextText(e.target.value)}
placeholder="Cole a matéria da aula, resumo de um livro ou o conteúdo do qual as questões devem ser retiradas..."
className="w-full px-4 py-3 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-sm min-h-[100px] resize-y"
/>
</div>
<div className="flex flex-col sm:flex-row gap-4 mt-6 pt-6 border-t border-slate-100 justify-end">
{!hasAPIKey ? (
<button
@ -825,7 +924,8 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
</button>
</div>
) : (
<label className="flex items-center justify-center gap-2 w-full md:w-auto px-6 py-4 bg-slate-50 border-2 border-dashed border-slate-300 rounded-xl cursor-pointer hover:bg-slate-100 transition-colors">
<div className="flex flex-col sm:flex-row gap-4 items-stretch">
<label className="flex flex-1 items-center justify-center gap-2 px-6 py-4 bg-slate-50 border-2 border-dashed border-slate-300 rounded-xl cursor-pointer hover:bg-slate-100 transition-colors">
<input
type="file"
accept="image/*"
@ -842,6 +942,27 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
</>
)}
</label>
{hasAPIKey && (
<button
type="button"
disabled={isGeneratingImage[question.id] || isUploading}
onClick={() => handleGenerateImageAI(qIndex)}
className="flex flex-1 items-center justify-center gap-2 px-6 py-4 bg-indigo-50 border-2 border-dashed border-indigo-300 text-indigo-600 rounded-xl hover:bg-indigo-100/50 transition-colors font-bold text-sm"
>
{isGeneratingImage[question.id] ? (
<>
<RefreshCw className="animate-spin text-indigo-600" size={20} />
<span>Gerando Imagem...</span>
</>
) : (
<>
<Sparkles size={20} className="text-indigo-500" />
<span>Gerar Imagem com IA</span>
</>
)}
</button>
)}
</div>
)}
</div>
@ -1188,7 +1309,7 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
</button>
</div>
<div className="space-y-5">
<div className="space-y-5 max-h-[60vh] overflow-y-auto pr-2">
<div>
<label className="block text-sm font-bold text-slate-700 mb-2">Provedor de IA Ativo</label>
<select
@ -1202,40 +1323,92 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
</select>
</div>
<div className="border-t border-slate-100 pt-4">
<h4 className="text-xs font-black text-slate-400 uppercase tracking-widest mb-3">Google Gemini</h4>
<div className="space-y-3">
<div>
<label className="block text-sm font-bold text-slate-700 mb-2">Chave Google Gemini</label>
<label className="block text-[11px] font-bold text-slate-500 mb-1">Chave de API</label>
<input
type="password"
value={aiForm.geminiKey}
onChange={e => setAiForm({ ...aiForm, geminiKey: e.target.value })}
placeholder="Cole a chave do Gemini (AIzaSy...)"
className="w-full px-4 py-3 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-sm"
placeholder="Cole a chave (AIzaSy...)"
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"
/>
</div>
<div>
<label className="block text-sm font-bold text-slate-700 mb-2">Chave OpenAI GPT</label>
<label className="block text-[11px] font-bold text-slate-500 mb-1">Modelo</label>
<select
value={aiForm.geminiModel}
onChange={e => setAiForm({ ...aiForm, geminiModel: e.target.value })}
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-bold text-slate-700 text-xs"
>
<option value="gemini-1.5-flash">gemini-1.5-flash (Mais Rápido & Gratuito)</option>
<option value="gemini-1.5-pro">gemini-1.5-pro (Multimodal)</option>
<option value="gemini-2.0-flash-exp">gemini-2.0-flash-exp (Experimental)</option>
<option value="gemini-2.5-flash">gemini-2.5-flash (Se ativo na sua conta)</option>
</select>
</div>
</div>
</div>
<div className="border-t border-slate-100 pt-4">
<h4 className="text-xs font-black text-slate-400 uppercase tracking-widest mb-3">OpenAI (ChatGPT)</h4>
<div className="space-y-3">
<div>
<label className="block text-[11px] font-bold text-slate-500 mb-1">Chave de API</label>
<input
type="password"
value={aiForm.openaiKey}
onChange={e => setAiForm({ ...aiForm, openaiKey: e.target.value })}
placeholder="Cole a chave da OpenAI (sk-...)"
className="w-full px-4 py-3 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-sm"
placeholder="Cole a chave (sk-...)"
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"
/>
</div>
<div>
<label className="block text-sm font-bold text-slate-700 mb-2">Chave Anthropic Claude</label>
<label className="block text-[11px] font-bold text-slate-500 mb-1">Modelo</label>
<select
value={aiForm.openaiModel}
onChange={e => setAiForm({ ...aiForm, openaiModel: e.target.value })}
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-bold text-slate-700 text-xs"
>
<option value="gpt-4o-mini">gpt-4o-mini (Recomendado)</option>
<option value="gpt-4o">gpt-4o (Completo)</option>
<option value="gpt-3.5-turbo">gpt-3.5-turbo (Legado)</option>
</select>
</div>
</div>
</div>
<div className="border-t border-slate-100 pt-4">
<h4 className="text-xs font-black text-slate-400 uppercase tracking-widest mb-3">Anthropic Claude</h4>
<div className="space-y-3">
<div>
<label className="block text-[11px] font-bold text-slate-500 mb-1">Chave de API</label>
<input
type="password"
value={aiForm.claudeKey}
onChange={e => setAiForm({ ...aiForm, claudeKey: e.target.value })}
placeholder="Cole a chave do Claude (sk-ant-...)"
className="w-full px-4 py-3 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-sm"
placeholder="Cole a chave (sk-ant-...)"
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"
/>
</div>
<div>
<label className="block text-[11px] font-bold text-slate-500 mb-1">Modelo</label>
<select
value={aiForm.claudeModel}
onChange={e => setAiForm({ ...aiForm, claudeModel: e.target.value })}
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-bold text-slate-700 text-xs"
>
<option value="claude-3-5-sonnet-20241022">claude-3-5-sonnet-20241022</option>
<option value="claude-3-5-haiku-20241022">claude-3-5-haiku-20241022</option>
</select>
</div>
</div>
</div>
</div>
<div className="flex gap-3 pt-4 border-t border-slate-100">
<div className="flex gap-3 pt-4 border-t border-slate-100 mt-5">
<button
type="button"
onClick={() => setShowAIConfigModal(false)}
@ -1253,7 +1426,6 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
</div>
</div>
</div>
</div>
)}
</div>
);

View File

@ -1261,7 +1261,7 @@ app.post('/api/provas/:id/questoes', async (req, res) => {
});
app.post('/api/ai/generate-questions', async (req, res) => {
const { topic, quantity, difficulty } = req.body;
const { topic, quantity, difficulty, contextText } = req.body;
if (!topic) {
return res.status(400).json({ error: 'Tema não informado' });
@ -1275,17 +1275,31 @@ app.post('/api/ai/generate-questions', async (req, res) => {
const provider = aiConfig.activeProvider || 'gemini';
let apiKey = '';
if (provider === 'gemini') apiKey = aiConfig.geminiKey;
else if (provider === 'openai') apiKey = aiConfig.openaiKey;
else if (provider === 'claude') apiKey = aiConfig.claudeKey;
let modelName = '';
if (provider === 'gemini') {
apiKey = aiConfig.geminiKey;
modelName = aiConfig.geminiModel || 'gemini-1.5-flash';
} else if (provider === 'openai') {
apiKey = aiConfig.openaiKey;
modelName = aiConfig.openaiModel || 'gpt-4o-mini';
} else if (provider === 'claude') {
apiKey = aiConfig.claudeKey;
modelName = aiConfig.claudeModel || 'claude-3-5-sonnet-20241022';
}
if (!apiKey) {
return res.status(400).json({ error: `Chave de API não configurada para o provedor ${provider.toUpperCase()} no painel da escola.` });
}
let userContent = `Gere exatamente ${limitQty} questões sobre o tema "${topic}" com nível de dificuldade "${difficulty}" em Português do Brasil.`;
if (contextText && contextText.trim()) {
userContent += `\n\nATENÇÃO: Você DEVE basear as questões EXCLUSIVAMENTE nas informações fornecidas no Material de Apoio abaixo:\n\n=== INÍCIO DO MATERIAL DE APOIO ===\n${contextText}\n=== FIM DO MATERIAL DE APOIO ===`;
}
if (provider === 'openai') {
const payload = {
model: 'gpt-4o-mini',
model: modelName,
messages: [
{
role: 'system',
@ -1309,7 +1323,7 @@ Exemplo de formato esperado:
},
{
role: 'user',
content: `Gere exatamente ${limitQty} questões sobre o tema "${topic}" com nível de dificuldade "${difficulty}" em Português do Brasil.`
content: userContent
}
],
response_format: { type: 'json_object' }
@ -1341,7 +1355,7 @@ Exemplo de formato esperado:
} else if (provider === 'claude') {
const payload = {
model: 'claude-3-5-sonnet-20241022',
model: modelName,
max_tokens: 4096,
system: `Você é um gerador de questões escolares de múltipla escolha para provas de alunos.
Retorne APENAS um array de objetos JSON estruturado (sem blocos de código markdown como \`\`\`json ou texto explicativo extra).
@ -1361,7 +1375,7 @@ Exemplo de formato esperado:
messages: [
{
role: 'user',
content: `Gere exatamente ${limitQty} questões escolares de múltipla escolha sobre o tema "${topic}" com nível de dificuldade "${difficulty}" em Português do Brasil.`
content: userContent
}
]
};
@ -1399,13 +1413,13 @@ Exemplo de formato esperado:
} else {
// Provedor padrão: Google Gemini
const geminiUrl = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${apiKey}`;
const geminiUrl = `https://generativelanguage.googleapis.com/v1beta/models/${modelName}:generateContent?key=${apiKey}`;
const payload = {
contents: [
{
parts: [
{
text: `Gere exatamente ${limitQty} questões de múltipla escolha escolares sobre o tema "${topic}" com nível de dificuldade "${difficulty}" em Português do Brasil.
text: `${userContent}
Apenas retorne o JSON estruturado contendo a lista de questões no seguinte formato de array:
[
{
@ -1468,6 +1482,143 @@ Apenas retorne o JSON estruturado contendo a lista de questões no seguinte form
}
});
app.post('/api/ai/generate-image', async (req, res) => {
const { prompt } = req.body;
if (!prompt) {
return res.status(400).json({ error: 'Prompt não informado' });
}
try {
const appData = await getSchoolData();
const aiConfig = appData.aiConfig || {};
let provider = '';
let apiKey = '';
if (aiConfig.openaiKey) {
provider = 'openai';
apiKey = aiConfig.openaiKey;
} else if (aiConfig.geminiKey) {
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.' });
}
let imageUrl = '';
if (provider === 'openai') {
const modelName = aiConfig.openaiModel || 'gpt-4o-mini';
const imageModel = modelName.includes('gpt-4') ? 'dall-e-3' : 'dall-e-2';
const response = await fetch('https://api.openai.com/v1/images/generations', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`
},
body: JSON.stringify({
prompt: `Ilustração educativa e profissional estilo flat vector sobre: ${prompt}`,
n: 1,
size: imageModel === 'dall-e-3' ? '1024x1024' : '512x512',
model: imageModel
})
});
if (!response.ok) {
const errorText = await response.text();
console.warn('Tentativa de geração de imagem falhou, tentando dall-e-2 de fallback. Erro:', errorText);
const responseD2 = await fetch('https://api.openai.com/v1/images/generations', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`
},
body: JSON.stringify({
prompt: `Ilustração educativa simples sobre: ${prompt}`,
n: 1,
size: '512x512',
model: 'dall-e-2'
})
});
if (!responseD2.ok) {
const errText2 = await responseD2.text();
throw new Error(`OpenAI DALL-E retornou erro: ${errText2}`);
}
const resJson2 = await responseD2.json();
imageUrl = resJson2.data?.[0]?.url;
} else {
const resJson = await response.json();
imageUrl = resJson.data?.[0]?.url;
}
} else {
// Usar Google Gemini Imagen 3
const imagenUrl = `https://generativelanguage.googleapis.com/v1beta/models/imagen-3.0-generate-002:predict?key=${apiKey}`;
const payload = {
instances: [
{
prompt: `Ilustração educativa flat vector sobre: ${prompt}`
}
],
parameters: {
sampleCount: 1,
aspectRatio: "1:1",
outputMimeType: "image/jpeg"
}
};
const response = await fetch(imagenUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!response.ok) {
const errText = await response.text();
throw new Error(`Gemini Imagen retornou erro: ${errText}`);
}
const resJson = await response.json();
const base64ImageBytes = resJson.predictions?.[0]?.bytesBase64Encoded;
if (!base64ImageBytes) {
throw new Error('Retorno vazio da API Imagen do Gemini');
}
const imageBuffer = Buffer.from(base64ImageBytes, 'base64');
const { uploadExamImage } = await import('./services/storage.js');
const savedUrl = await uploadExamImage(imageBuffer, 'image/jpeg');
return res.json({ url: savedUrl });
}
if (!imageUrl) {
throw new Error('Não foi possível obter a URL da imagem gerada');
}
// Baixar imagem gerada e enviar ao MinIO
const imgResponse = await fetch(imageUrl);
if (!imgResponse.ok) {
throw new Error(`Erro ao baixar imagem da IA: ${imgResponse.statusText}`);
}
const contentType = imgResponse.headers.get('content-type') || 'image/png';
const blobBuffer = Buffer.from(await imgResponse.arrayBuffer());
const { uploadExamImage } = await import('./services/storage.js');
const savedUrl = await uploadExamImage(blobBuffer, contentType);
res.json({ url: savedUrl });
} catch (error) {
console.error('Erro ao gerar imagem com IA:', error);
res.status(500).json({ error: error.message || 'Erro ao gerar ou salvar imagem da IA' });
}
});
// Helper para formatar datas para o JSON
const formatDateForJSON = (val) => {
if (!val) return '';

View File

@ -302,6 +302,9 @@ export interface AIConfig {
geminiKey?: string;
openaiKey?: string;
claudeKey?: string;
geminiModel?: string;
openaiModel?: string;
claudeModel?: string;
}
export interface SchoolData {