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',
|
||||
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,23 +924,45 @@ 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">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => handleImageUpload(qIndex, e)}
|
||||
disabled={isUploading}
|
||||
/>
|
||||
{isUploading ? (
|
||||
<span className="text-slate-500 font-bold text-sm">Enviando...</span>
|
||||
) : (
|
||||
<>
|
||||
<ImageIcon size={20} className="text-slate-400" />
|
||||
<span className="text-slate-500 font-bold text-sm">Adicionar Imagem</span>
|
||||
</>
|
||||
<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/*"
|
||||
className="hidden"
|
||||
onChange={(e) => handleImageUpload(qIndex, e)}
|
||||
disabled={isUploading}
|
||||
/>
|
||||
{isUploading ? (
|
||||
<span className="text-slate-500 font-bold text-sm">Enviando...</span>
|
||||
) : (
|
||||
<>
|
||||
<ImageIcon size={20} className="text-slate-400" />
|
||||
<span className="text-slate-500 font-bold text-sm">Adicionar Imagem</span>
|
||||
</>
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
</label>
|
||||
</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,55 +1323,106 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
|
|||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-slate-700 mb-2">Chave Google Gemini</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"
|
||||
/>
|
||||
<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-[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 (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-[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>
|
||||
<label className="block text-sm font-bold text-slate-700 mb-2">Chave OpenAI GPT</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"
|
||||
/>
|
||||
<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 (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-[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>
|
||||
<label className="block text-sm font-bold text-slate-700 mb-2">Chave Anthropic Claude</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"
|
||||
/>
|
||||
<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 (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">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAIConfigModal(false)}
|
||||
className="flex-1 px-6 py-3 bg-slate-100 text-slate-600 rounded-xl font-bold hover:bg-slate-200 transition-all text-sm"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSaveAIConfig}
|
||||
className="flex-1 px-6 py-3 bg-indigo-600 text-white rounded-xl font-bold hover:bg-indigo-700 transition-all shadow-lg shadow-indigo-100 text-sm"
|
||||
>
|
||||
Salvar
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex gap-3 pt-4 border-t border-slate-100 mt-5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAIConfigModal(false)}
|
||||
className="flex-1 px-6 py-3 bg-slate-100 text-slate-600 rounded-xl font-bold hover:bg-slate-200 transition-all text-sm"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSaveAIConfig}
|
||||
className="flex-1 px-6 py-3 bg-indigo-600 text-white rounded-xl font-bold hover:bg-indigo-700 transition-all shadow-lg shadow-indigo-100 text-sm"
|
||||
>
|
||||
Salvar
|
||||
</button>
|
||||
</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) => {
|
||||
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 '';
|
||||
|
|
|
|||
|
|
@ -302,6 +302,9 @@ export interface AIConfig {
|
|||
geminiKey?: string;
|
||||
openaiKey?: string;
|
||||
claudeKey?: string;
|
||||
geminiModel?: string;
|
||||
openaiModel?: string;
|
||||
claudeModel?: string;
|
||||
}
|
||||
|
||||
export interface SchoolData {
|
||||
|
|
|
|||
Loading…
Reference in New Issue