mirror of
https://github.com/lucasrcsantana/story-generator.git
synced 2025-12-17 22:07:52 +00:00
Some checks failed
Docker Build and Push / build (push) Has been cancelled
- Integra completamente com a tabela languages - Adiciona suporte para ícones de bandeira e instruções - Remove LANGUAGE_OPTIONS hard coded - Usa DEFAULT_LANGUAGE do type - Melhora validações e UX do seletor de idiomas - Atualiza CHANGELOG.md para versão 1.4.0
615 lines
20 KiB
TypeScript
615 lines
20 KiB
TypeScript
import React from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { supabase } from '../../lib/supabase';
|
|
import { useSession } from '../../hooks/useSession';
|
|
import { useStoryCategories } from '../../hooks/useStoryCategories';
|
|
import { Wand2, ArrowLeft, Globe } from 'lucide-react';
|
|
import { useStudentTracking } from '../../hooks/useStudentTracking';
|
|
import { useLanguages } from '../../hooks/useLanguages';
|
|
|
|
interface Category {
|
|
id: string;
|
|
slug: string;
|
|
title: string;
|
|
description: string;
|
|
icon: string;
|
|
}
|
|
|
|
interface StoryStep {
|
|
title: string;
|
|
key?: keyof StoryChoices;
|
|
items?: Category[];
|
|
isContextStep?: boolean;
|
|
isLanguageStep?: boolean;
|
|
}
|
|
|
|
export interface StoryChoices {
|
|
theme_id: string | null;
|
|
subject_id: string | null;
|
|
character_id: string | null;
|
|
setting_id: string | null;
|
|
context?: string;
|
|
language_type: string;
|
|
}
|
|
|
|
interface StoryGeneratorProps {
|
|
initialContext?: string;
|
|
onContextChange: (context: string) => void;
|
|
inputMode: 'voice' | 'form';
|
|
voiceTranscript: string;
|
|
isGenerating: boolean;
|
|
setIsGenerating: (value: boolean) => void;
|
|
step: number;
|
|
setStep: (value: number | ((prev: number) => number)) => void;
|
|
choices: StoryChoices;
|
|
setChoices: React.Dispatch<React.SetStateAction<StoryChoices>>;
|
|
}
|
|
|
|
export function StoryGenerator({
|
|
initialContext = '',
|
|
onContextChange,
|
|
inputMode,
|
|
voiceTranscript,
|
|
isGenerating,
|
|
setIsGenerating,
|
|
step,
|
|
setStep,
|
|
choices,
|
|
setChoices
|
|
}: StoryGeneratorProps) {
|
|
const { themes, subjects, characters, settings, isLoading: isCategoriesLoading } = useStoryCategories();
|
|
const { languages, supportedLanguages, isLoading: isLanguagesLoading } = useLanguages();
|
|
|
|
// Definir steps com os dados obtidos
|
|
const steps: StoryStep[] = [
|
|
{
|
|
title: 'Escolha o Tema',
|
|
items: themes || [],
|
|
key: 'theme_id'
|
|
},
|
|
{
|
|
title: 'Escolha a Disciplina',
|
|
items: subjects || [],
|
|
key: 'subject_id'
|
|
},
|
|
{
|
|
title: 'Escolha o Personagem',
|
|
items: characters || [],
|
|
key: 'character_id'
|
|
},
|
|
{
|
|
title: 'Escolha o Cenário',
|
|
items: settings || [],
|
|
key: 'setting_id'
|
|
},
|
|
{
|
|
title: 'Escolha o Idioma da História',
|
|
isLanguageStep: true
|
|
},
|
|
{
|
|
title: 'Contexto da História (Opcional)',
|
|
isContextStep: true
|
|
}
|
|
];
|
|
|
|
// useEffect que depende dos dados
|
|
React.useEffect(() => {
|
|
// Só aplicar escolhas aleatórias se estiver no modo voz
|
|
if (inputMode === 'voice' && voiceTranscript && themes && !choices.theme_id) {
|
|
setStep(steps.length); // Vai para o último passo (contexto)
|
|
// Selecionar IDs aleatórios válidos para cada categoria
|
|
const randomTheme = themes[Math.floor(Math.random() * themes.length)];
|
|
const randomSubject = subjects?.[Math.floor(Math.random() * (subjects?.length || 1))] || null;
|
|
const randomCharacter = characters?.[Math.floor(Math.random() * (characters?.length || 1))] || null;
|
|
const randomSetting = settings?.[Math.floor(Math.random() * (settings?.length || 1))] || null;
|
|
|
|
setChoices(prev => ({
|
|
...prev,
|
|
theme_id: randomTheme?.id || null,
|
|
subject_id: randomSubject?.id || null,
|
|
character_id: randomCharacter?.id || null,
|
|
setting_id: randomSetting?.id || null,
|
|
language_type: prev.language_type // Mantém o idioma selecionado
|
|
}));
|
|
}
|
|
}, [inputMode, voiceTranscript, themes, subjects, characters, settings, setStep, setChoices, choices.theme_id]);
|
|
|
|
// Atualizar apenas o contexto quando mudar o modo ou a transcrição
|
|
React.useEffect(() => {
|
|
if (inputMode === 'voice' && voiceTranscript) {
|
|
setChoices(prev => ({
|
|
...prev,
|
|
context: voiceTranscript
|
|
}));
|
|
} else if (inputMode === 'form') {
|
|
setChoices(prev => ({
|
|
...prev,
|
|
context: initialContext
|
|
}));
|
|
}
|
|
}, [voiceTranscript, initialContext, inputMode]);
|
|
|
|
const handleContextChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
onContextChange(e.target.value);
|
|
};
|
|
|
|
const navigate = useNavigate();
|
|
const { session } = useSession();
|
|
const [error, setError] = React.useState<string | null>(null);
|
|
const [generationStatus, setGenerationStatus] = React.useState<
|
|
'idle' | 'creating' | 'generating-images' | 'saving'
|
|
>('idle');
|
|
const { trackStoryGenerated } = useStudentTracking();
|
|
const startTime = React.useRef(Date.now());
|
|
|
|
const currentStep = steps[step - 1];
|
|
|
|
const handleSelect = (key: keyof StoryChoices, value: string) => {
|
|
console.log(`Selecionando ${key}:`, value); // Log para debug
|
|
|
|
if (!value) {
|
|
setError(`Valor inválido para ${key}`);
|
|
return;
|
|
}
|
|
|
|
setChoices(prev => ({ ...prev, [key]: value }));
|
|
|
|
// Avançar apenas se houver um próximo passo
|
|
if (step < steps.length) {
|
|
setStep((prev: number) => prev + 1);
|
|
}
|
|
};
|
|
|
|
const handleNext = () => {
|
|
if (currentStep.isContextStep) {
|
|
setStep((prev: number) => prev + 1);
|
|
}
|
|
};
|
|
|
|
const handleLanguageSelect = (language: string) => {
|
|
console.log('Selecionando idioma:', language);
|
|
|
|
const selectedLanguage = languages.find(lang => lang.code === language);
|
|
if (!selectedLanguage) {
|
|
setError('Idioma inválido selecionado');
|
|
return;
|
|
}
|
|
|
|
setChoices(prev => ({
|
|
...prev,
|
|
language_type: language
|
|
}));
|
|
|
|
// Avançar para o próximo passo
|
|
if (step < steps.length) {
|
|
setStep((prev: number) => prev + 1);
|
|
}
|
|
};
|
|
|
|
const handleGenerate = async () => {
|
|
// Validação apenas para modo voz
|
|
if (inputMode === 'voice' && !voiceTranscript) {
|
|
setError('Grave uma descrição por voz antes de enviar');
|
|
return;
|
|
}
|
|
|
|
// Contexto é opcional no formulário
|
|
const finalContext = inputMode === 'voice' ? voiceTranscript : initialContext;
|
|
|
|
if (!session?.user?.id) {
|
|
setError('Usuário não autenticado');
|
|
return;
|
|
}
|
|
|
|
// Log inicial para debug
|
|
console.log('=== Iniciando geração de história ===');
|
|
console.log('Modo:', inputMode);
|
|
console.log('Choices:', choices);
|
|
|
|
// Validações iniciais
|
|
if (!themes?.length || !subjects?.length || !characters?.length || !settings?.length) {
|
|
console.error('Dados das categorias não carregados:', { themes, subjects, characters, settings });
|
|
setError('Erro ao carregar dados necessários. Tente novamente.');
|
|
return;
|
|
}
|
|
|
|
// Validar se todos os IDs são UUIDs válidos
|
|
const isValidUUID = (id: string | null) => {
|
|
if (!id) return false;
|
|
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
return uuidRegex.test(id);
|
|
};
|
|
|
|
// Validar cada ID individualmente
|
|
const validations = [
|
|
{ field: 'theme_id', value: choices.theme_id, exists: themes.some(t => t.id === choices.theme_id) },
|
|
{ field: 'subject_id', value: choices.subject_id, exists: subjects.some(s => s.id === choices.subject_id) },
|
|
{ field: 'character_id', value: choices.character_id, exists: characters.some(c => c.id === choices.character_id) },
|
|
{ field: 'setting_id', value: choices.setting_id, exists: settings.some(s => s.id === choices.setting_id) }
|
|
];
|
|
|
|
// Verificar cada validação
|
|
for (const validation of validations) {
|
|
console.log(`Validando ${validation.field}:`, validation);
|
|
|
|
if (!validation.value) {
|
|
setError(`${validation.field} não selecionado`);
|
|
return;
|
|
}
|
|
|
|
if (!isValidUUID(validation.value)) {
|
|
setError(`${validation.field} não é um UUID válido`);
|
|
return;
|
|
}
|
|
|
|
if (!validation.exists) {
|
|
setError(`${validation.field} não encontrado na lista de opções`);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Validar idioma
|
|
if (!choices.language_type || !languages.some(lang => lang.code === choices.language_type)) {
|
|
setError('Idioma não selecionado ou inválido');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setIsGenerating(true);
|
|
setError(null);
|
|
setGenerationStatus('creating');
|
|
|
|
// Log detalhado antes de fazer a inserção
|
|
console.log('=== Dados validados para inserção ===', {
|
|
student_id: session.user.id,
|
|
theme_id: choices.theme_id,
|
|
subject_id: choices.subject_id,
|
|
character_id: choices.character_id,
|
|
setting_id: choices.setting_id,
|
|
context: finalContext,
|
|
language_type: choices.language_type
|
|
});
|
|
|
|
// Criar objeto da história antes da inserção para validação
|
|
const storyData = {
|
|
student_id: session.user.id,
|
|
title: 'Gerando...',
|
|
theme_id: choices.theme_id,
|
|
subject_id: choices.subject_id,
|
|
character_id: choices.character_id,
|
|
setting_id: choices.setting_id,
|
|
context: finalContext,
|
|
language_type: choices.language_type,
|
|
status: 'draft',
|
|
content: {
|
|
prompt: choices,
|
|
pages: []
|
|
}
|
|
} as const;
|
|
|
|
// Validar se todos os campos necessários estão presentes
|
|
const requiredFields = ['student_id', 'theme_id', 'subject_id', 'character_id', 'setting_id', 'language_type'] as const;
|
|
const missingFields = requiredFields.filter(field => !storyData[field]);
|
|
|
|
if (missingFields.length > 0) {
|
|
throw new Error(`Campos obrigatórios faltando: ${missingFields.join(', ')}`);
|
|
}
|
|
|
|
const { data: story, error: storyError } = await supabase
|
|
.from('stories')
|
|
.insert(storyData)
|
|
.select()
|
|
.single();
|
|
|
|
if (storyError) {
|
|
console.error('Erro ao inserir história:', storyError);
|
|
throw storyError;
|
|
}
|
|
|
|
// Tracking da criação da história
|
|
const selectedTheme = themes?.find(t => t.id === choices.theme_id)?.title || '';
|
|
const selectedSubject = subjects?.find(s => s.id === choices.subject_id)?.title || '';
|
|
const selectedCharacter = characters?.find(c => c.id === choices.character_id)?.title || '';
|
|
const selectedSetting = settings?.find(s => s.id === choices.setting_id)?.title || '';
|
|
|
|
trackStoryGenerated({
|
|
story_id: story.id,
|
|
theme: selectedTheme,
|
|
subject: selectedSubject,
|
|
character: selectedCharacter,
|
|
setting: selectedSetting,
|
|
context: finalContext,
|
|
generation_time: Date.now() - startTime.current,
|
|
word_count: 0,
|
|
student_id: session.user.id,
|
|
school_id: session.user.user_metadata?.school_id,
|
|
class_id: session.user.user_metadata?.class_id
|
|
});
|
|
|
|
setGenerationStatus('generating-images');
|
|
console.log('=== Chamando Edge Function ===');
|
|
console.log('Story ID:', story.id);
|
|
console.log('Story Data:', story);
|
|
|
|
try {
|
|
if (!story?.id) {
|
|
throw new Error('ID da história não encontrado');
|
|
}
|
|
|
|
const storyPayload = {
|
|
voice_context: finalContext || '',
|
|
student_id: session.user.id,
|
|
theme_id: choices.theme_id,
|
|
subject_id: choices.subject_id,
|
|
character_id: choices.character_id,
|
|
setting_id: choices.setting_id,
|
|
language_type: choices.language_type,
|
|
theme: selectedTheme,
|
|
subject: selectedSubject,
|
|
character: selectedCharacter,
|
|
setting: selectedSetting,
|
|
story_id: story.id // Garantindo que o ID existe
|
|
};
|
|
|
|
console.log('=== Dados da História ===');
|
|
console.log('ID:', story.id);
|
|
console.log('Payload completo:', storyPayload);
|
|
|
|
const response = await supabase.functions
|
|
.invoke('generate-story', {
|
|
body: storyPayload
|
|
});
|
|
|
|
console.log('=== Resposta da Edge Function ===');
|
|
console.log('Resposta completa:', response);
|
|
|
|
// Se a resposta não for 200, lançar erro
|
|
if (response.error) {
|
|
console.error('Erro na Edge Function:', response.error);
|
|
throw new Error(`Erro na Edge Function: ${response.error.message}`);
|
|
}
|
|
|
|
// Se não houver dados na resposta
|
|
if (!response.data) {
|
|
console.error('Edge Function não retornou dados');
|
|
throw new Error('Edge Function não retornou dados');
|
|
}
|
|
|
|
// Atualizar o status da história para success
|
|
const { error: updateError } = await supabase
|
|
.from('stories')
|
|
.update({
|
|
status: 'published',
|
|
updated_at: new Date().toISOString()
|
|
})
|
|
.eq('id', story.id)
|
|
.single();
|
|
|
|
if (updateError) {
|
|
console.error('Erro ao atualizar status da história:', updateError);
|
|
throw updateError;
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error('=== Erro na Edge Function ===');
|
|
console.error('Erro completo:', error);
|
|
console.error('Story ID:', story?.id);
|
|
console.error('Estado atual:', { choices, inputMode, step });
|
|
|
|
if (!story?.id) {
|
|
throw new Error('ID da história não encontrado para atualizar status de erro');
|
|
}
|
|
|
|
// Atualizar status da história para erro
|
|
const { error: updateError } = await supabase
|
|
.from('stories')
|
|
.update({
|
|
status: 'failed',
|
|
title: 'Erro na Geração',
|
|
updated_at: new Date().toISOString()
|
|
})
|
|
.eq('id', story.id)
|
|
.single();
|
|
|
|
if (updateError) {
|
|
console.error('Erro ao atualizar status de erro:', updateError);
|
|
}
|
|
|
|
throw new Error(`Erro na geração da história. Por favor, tente novamente.`);
|
|
}
|
|
|
|
setGenerationStatus('saving');
|
|
const { data: updatedStory, error: updateError } = await supabase
|
|
.from('stories')
|
|
.select('*')
|
|
.eq('id', story.id)
|
|
.single();
|
|
|
|
if (updateError) {
|
|
console.error('Erro ao buscar história atualizada:', updateError);
|
|
throw updateError;
|
|
}
|
|
|
|
// Atualizar a contagem de palavras após a geração
|
|
const wordCount = updatedStory.content.pages.reduce((acc: number, page: { text: string }) =>
|
|
acc + page.text.split(/\s+/).length, 0);
|
|
|
|
await supabase.from('story_metrics').insert({
|
|
story_id: story.id,
|
|
word_count: wordCount,
|
|
generation_time: Date.now() - startTime.current
|
|
});
|
|
|
|
navigate(`/aluno/historias/${story.id}`);
|
|
} catch (err) {
|
|
console.error('=== Erro detalhado ===');
|
|
console.error('Erro:', err);
|
|
console.error('Estado atual:', { choices, inputMode, step });
|
|
|
|
if (err instanceof Error) {
|
|
setError(`Erro ao gerar história: ${err.message}`);
|
|
} else {
|
|
setError('Erro desconhecido ao gerar história. Tente novamente.');
|
|
}
|
|
} finally {
|
|
setIsGenerating(false);
|
|
setGenerationStatus('idle');
|
|
}
|
|
};
|
|
|
|
const getGenerationStatusText = () => {
|
|
switch (generationStatus) {
|
|
case 'creating':
|
|
return 'Iniciando criação...';
|
|
case 'generating-images':
|
|
return 'Gerando história e imagens...';
|
|
case 'saving':
|
|
return 'Finalizando...';
|
|
default:
|
|
return 'Criar História Mágica';
|
|
}
|
|
};
|
|
|
|
if (isCategoriesLoading || isLanguagesLoading) {
|
|
return (
|
|
<div className="animate-pulse space-y-8">
|
|
<div className="h-2 bg-gray-200 rounded-full" />
|
|
<div className="grid grid-cols-2 gap-4">
|
|
{[...Array(4)].map((_, i) => (
|
|
<div key={i} className="h-32 bg-gray-200 rounded-xl" />
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-8">
|
|
{/* Progress Bar */}
|
|
<div className="flex gap-2 mb-8">
|
|
{steps.map((s, i) => (
|
|
<div
|
|
key={i}
|
|
className={`h-2 rounded-full flex-1 ${
|
|
i + 1 <= step ? 'bg-purple-600' : 'bg-gray-200'
|
|
}`}
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
<h2 className="text-xl font-medium text-gray-900 mb-6">
|
|
{currentStep.title}
|
|
</h2>
|
|
|
|
{currentStep.isLanguageStep ? (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
{supportedLanguages.map((option) => {
|
|
const languageDetails = languages.find(lang => lang.code === option.value);
|
|
return (
|
|
<button
|
|
key={option.value}
|
|
onClick={() => handleLanguageSelect(option.value)}
|
|
className={`p-6 rounded-xl border-2 transition-all text-left ${
|
|
choices.language_type === option.value
|
|
? 'border-purple-500 bg-purple-50'
|
|
: 'border-gray-200 hover:border-purple-200 hover:bg-gray-50'
|
|
}`}
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
{languageDetails?.flag_icon ? (
|
|
<img
|
|
src={languageDetails.flag_icon}
|
|
alt={`Bandeira ${option.label}`}
|
|
className="h-6 w-6 object-cover rounded-full"
|
|
/>
|
|
) : (
|
|
<Globe className="h-6 w-6 text-purple-600" />
|
|
)}
|
|
<div>
|
|
<h3 className="font-medium text-gray-900">{option.label}</h3>
|
|
<p className="text-sm text-gray-600">
|
|
{`Escreva sua história em ${option.label}`}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
) : currentStep.isContextStep ? (
|
|
<div className="space-y-4">
|
|
<textarea
|
|
value={initialContext}
|
|
onChange={handleContextChange}
|
|
className="w-full p-3 border rounded-lg"
|
|
placeholder="Descreva sua história... (opcional)"
|
|
/>
|
|
<button
|
|
onClick={handleGenerate}
|
|
disabled={isGenerating}
|
|
className="w-full flex items-center justify-center gap-2 px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50"
|
|
>
|
|
<Wand2 className="h-5 w-5" />
|
|
{isGenerating ? getGenerationStatusText() : 'Criar História Mágica'}
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
{currentStep.items?.map((item) => (
|
|
<button
|
|
key={item.id}
|
|
onClick={() => handleSelect(currentStep.key!, item.id)}
|
|
className={`p-6 rounded-xl border-2 transition-all text-left ${
|
|
choices[currentStep.key!] === item.id
|
|
? 'border-purple-500 bg-purple-50'
|
|
: 'border-gray-200 hover:border-purple-200 hover:bg-gray-50'
|
|
}`}
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
<span className="text-2xl">{item.icon}</span>
|
|
<div>
|
|
<h3 className="font-medium text-gray-900">{item.title}</h3>
|
|
<p className="text-sm text-gray-600">{item.description}</p>
|
|
</div>
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{error && (
|
|
<div className="p-4 bg-red-50 text-red-600 rounded-lg text-sm">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{choices.theme_id === 'auto' && (
|
|
<div className="mb-4 p-3 bg-blue-50 text-blue-600 rounded-lg">
|
|
Configurações automáticas selecionadas com base na descrição por voz
|
|
</div>
|
|
)}
|
|
|
|
{/* Navigation Buttons */}
|
|
<div className="flex justify-between pt-6">
|
|
<button
|
|
onClick={() => setStep((prev: number) => prev - 1)}
|
|
disabled={step === 1}
|
|
className="flex items-center gap-2 px-4 py-2 text-gray-600 disabled:opacity-50"
|
|
>
|
|
<ArrowLeft className="h-5 w-5" />
|
|
Voltar
|
|
</button>
|
|
|
|
{!currentStep.isLanguageStep && !currentStep.isContextStep && (
|
|
<button
|
|
onClick={handleNext}
|
|
disabled={currentStep.key && !choices[currentStep.key] || isGenerating}
|
|
className="flex items-center gap-2 px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50"
|
|
>
|
|
Próximo
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|