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
Build and Deploy (Gitea) / build-and-deploy (push) Successful in 2m4s
Details
This commit is contained in:
parent
6816c945cc
commit
de08900fbe
|
|
@ -25,20 +25,28 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
|
||||||
activeProvider: data.aiConfig?.activeProvider || 'gemini',
|
activeProvider: data.aiConfig?.activeProvider || 'gemini',
|
||||||
geminiKey: data.aiConfig?.geminiKey ? '********' : '',
|
geminiKey: data.aiConfig?.geminiKey ? '********' : '',
|
||||||
openaiKey: data.aiConfig?.openaiKey ? '********' : '',
|
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 [isGeneratingAI, setIsGeneratingAI] = useState(false);
|
||||||
const [aiTopic, setAiTopic] = useState('');
|
const [aiTopic, setAiTopic] = useState('');
|
||||||
const [aiQuantity, setAiQuantity] = useState(5);
|
const [aiQuantity, setAiQuantity] = useState(5);
|
||||||
const [aiDifficulty, setAiDifficulty] = useState('medium');
|
const [aiDifficulty, setAiDifficulty] = useState('medium');
|
||||||
|
const [aiContextText, setAiContextText] = useState('');
|
||||||
|
const [isGeneratingImage, setIsGeneratingImage] = useState<{[key: string]: boolean}>({});
|
||||||
|
|
||||||
const handleOpenAIConfig = () => {
|
const handleOpenAIConfig = () => {
|
||||||
setAiForm({
|
setAiForm({
|
||||||
activeProvider: data.aiConfig?.activeProvider || 'gemini',
|
activeProvider: data.aiConfig?.activeProvider || 'gemini',
|
||||||
geminiKey: data.aiConfig?.geminiKey ? '********' : '',
|
geminiKey: data.aiConfig?.geminiKey ? '********' : '',
|
||||||
openaiKey: data.aiConfig?.openaiKey ? '********' : '',
|
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);
|
setShowAIConfigModal(true);
|
||||||
};
|
};
|
||||||
|
|
@ -48,7 +56,10 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
|
||||||
activeProvider: aiForm.activeProvider,
|
activeProvider: aiForm.activeProvider,
|
||||||
geminiKey: aiForm.geminiKey === '********' ? (data.aiConfig?.geminiKey || '') : aiForm.geminiKey,
|
geminiKey: aiForm.geminiKey === '********' ? (data.aiConfig?.geminiKey || '') : aiForm.geminiKey,
|
||||||
openaiKey: aiForm.openaiKey === '********' ? (data.aiConfig?.openaiKey || '') : aiForm.openaiKey,
|
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 {
|
try {
|
||||||
|
|
@ -295,7 +306,8 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
topic: aiTopic,
|
topic: aiTopic,
|
||||||
quantity: qty,
|
quantity: qty,
|
||||||
difficulty: aiDifficulty
|
difficulty: aiDifficulty,
|
||||||
|
contextText: aiContextText
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -356,7 +368,8 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
topic: topic,
|
topic: topic,
|
||||||
quantity: 1,
|
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 = () => {
|
const handleAddQuestion = () => {
|
||||||
if (!editingExam) return;
|
if (!editingExam) return;
|
||||||
setEditingExam({
|
setEditingExam({
|
||||||
|
|
@ -723,6 +787,41 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="flex flex-col sm:flex-row gap-4 mt-6 pt-6 border-t border-slate-100 justify-end">
|
||||||
{!hasAPIKey ? (
|
{!hasAPIKey ? (
|
||||||
<button
|
<button
|
||||||
|
|
@ -825,7 +924,8 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/*"
|
accept="image/*"
|
||||||
|
|
@ -842,6 +942,27 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</label>
|
</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>
|
</div>
|
||||||
|
|
||||||
|
|
@ -1188,7 +1309,7 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-5">
|
<div className="space-y-5 max-h-[60vh] overflow-y-auto pr-2">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-bold text-slate-700 mb-2">Provedor de IA Ativo</label>
|
<label className="block text-sm font-bold text-slate-700 mb-2">Provedor de IA Ativo</label>
|
||||||
<select
|
<select
|
||||||
|
|
@ -1202,40 +1323,92 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
|
||||||
</select>
|
</select>
|
||||||
</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">Google Gemini</h4>
|
||||||
|
<div className="space-y-3">
|
||||||
<div>
|
<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
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
value={aiForm.geminiKey}
|
value={aiForm.geminiKey}
|
||||||
onChange={e => setAiForm({ ...aiForm, geminiKey: e.target.value })}
|
onChange={e => setAiForm({ ...aiForm, geminiKey: e.target.value })}
|
||||||
placeholder="Cole a chave do Gemini (AIzaSy...)"
|
placeholder="Cole a chave (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"
|
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>
|
||||||
|
|
||||||
<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
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
value={aiForm.openaiKey}
|
value={aiForm.openaiKey}
|
||||||
onChange={e => setAiForm({ ...aiForm, openaiKey: e.target.value })}
|
onChange={e => setAiForm({ ...aiForm, openaiKey: e.target.value })}
|
||||||
placeholder="Cole a chave da OpenAI (sk-...)"
|
placeholder="Cole a chave (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"
|
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>
|
||||||
|
|
||||||
<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
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
value={aiForm.claudeKey}
|
value={aiForm.claudeKey}
|
||||||
onChange={e => setAiForm({ ...aiForm, claudeKey: e.target.value })}
|
onChange={e => setAiForm({ ...aiForm, claudeKey: e.target.value })}
|
||||||
placeholder="Cole a chave do Claude (sk-ant-...)"
|
placeholder="Cole a chave (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"
|
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>
|
||||||
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowAIConfigModal(false)}
|
onClick={() => setShowAIConfigModal(false)}
|
||||||
|
|
@ -1253,7 +1426,6 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1261,7 +1261,7 @@ app.post('/api/provas/:id/questoes', async (req, res) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/ai/generate-questions', 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) {
|
if (!topic) {
|
||||||
return res.status(400).json({ error: 'Tema não informado' });
|
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';
|
const provider = aiConfig.activeProvider || 'gemini';
|
||||||
|
|
||||||
let apiKey = '';
|
let apiKey = '';
|
||||||
if (provider === 'gemini') apiKey = aiConfig.geminiKey;
|
let modelName = '';
|
||||||
else if (provider === 'openai') apiKey = aiConfig.openaiKey;
|
|
||||||
else if (provider === 'claude') apiKey = aiConfig.claudeKey;
|
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) {
|
if (!apiKey) {
|
||||||
return res.status(400).json({ error: `Chave de API não configurada para o provedor ${provider.toUpperCase()} no painel da escola.` });
|
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') {
|
if (provider === 'openai') {
|
||||||
const payload = {
|
const payload = {
|
||||||
model: 'gpt-4o-mini',
|
model: modelName,
|
||||||
messages: [
|
messages: [
|
||||||
{
|
{
|
||||||
role: 'system',
|
role: 'system',
|
||||||
|
|
@ -1309,7 +1323,7 @@ Exemplo de formato esperado:
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
role: 'user',
|
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' }
|
response_format: { type: 'json_object' }
|
||||||
|
|
@ -1341,7 +1355,7 @@ Exemplo de formato esperado:
|
||||||
|
|
||||||
} else if (provider === 'claude') {
|
} else if (provider === 'claude') {
|
||||||
const payload = {
|
const payload = {
|
||||||
model: 'claude-3-5-sonnet-20241022',
|
model: modelName,
|
||||||
max_tokens: 4096,
|
max_tokens: 4096,
|
||||||
system: `Você é um gerador de questões escolares de múltipla escolha para provas de alunos.
|
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).
|
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: [
|
messages: [
|
||||||
{
|
{
|
||||||
role: 'user',
|
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 {
|
} else {
|
||||||
// Provedor padrão: Google Gemini
|
// 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 = {
|
const payload = {
|
||||||
contents: [
|
contents: [
|
||||||
{
|
{
|
||||||
parts: [
|
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:
|
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
|
// Helper para formatar datas para o JSON
|
||||||
const formatDateForJSON = (val) => {
|
const formatDateForJSON = (val) => {
|
||||||
if (!val) return '';
|
if (!val) return '';
|
||||||
|
|
|
||||||
|
|
@ -302,6 +302,9 @@ export interface AIConfig {
|
||||||
geminiKey?: string;
|
geminiKey?: string;
|
||||||
openaiKey?: string;
|
openaiKey?: string;
|
||||||
claudeKey?: string;
|
claudeKey?: string;
|
||||||
|
geminiModel?: string;
|
||||||
|
openaiModel?: string;
|
||||||
|
claudeModel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SchoolData {
|
export interface SchoolData {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue