feat: implementa geração de histórias com IA

- Adiciona integração com OpenAI GPT e DALL-E
- Implementa fluxo de geração de histórias
- Adiciona feedback visual do processo
- Melhora tratamento de erros
- Adiciona logs para debug

Resolves: #FEAT-123
This commit is contained in:
Lucas Santana 2024-12-23 09:03:23 -03:00
parent 3701e692f1
commit 03732de610
7 changed files with 447 additions and 191 deletions

27
package-lock.json generated
View File

@ -11,6 +11,7 @@
"@radix-ui/react-accordion": "^1.2.2",
"@radix-ui/react-tabs": "^1.1.2",
"@supabase/supabase-js": "^2.39.7",
"@tanstack/react-query": "^5.62.8",
"clsx": "^2.1.1",
"lucide-react": "^0.344.0",
"react": "^18.3.1",
@ -1673,6 +1674,32 @@
"@supabase/storage-js": "2.7.1"
}
},
"node_modules/@tanstack/query-core": {
"version": "5.62.8",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.62.8.tgz",
"integrity": "sha512-4fV31vDsUyvNGrKIOUNPrZztoyL187bThnoQOvAXEVlZbSiuPONpfx53634MKKdvsDir5NyOGm80ShFaoHS/mw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/react-query": {
"version": "5.62.8",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.62.8.tgz",
"integrity": "sha512-8TUstKxF/fysHonZsWg/hnlDVgasTdHx6Q+f1/s/oPKJBJbKUWPZEHwLTMOZgrZuroLMiqYKJ9w69Abm8mWP0Q==",
"license": "MIT",
"dependencies": {
"@tanstack/query-core": "5.62.8"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^18 || ^19"
}
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",

View File

@ -15,6 +15,7 @@
"@radix-ui/react-accordion": "^1.2.2",
"@radix-ui/react-tabs": "^1.1.2",
"@supabase/supabase-js": "^2.39.7",
"@tanstack/react-query": "^5.62.8",
"clsx": "^2.1.1",
"lucide-react": "^0.344.0",
"react": "^18.3.1",

View File

@ -10,6 +10,8 @@ import { AuthUser, SavedStory } from './types/auth';
import { User, Theme } from './types';
import { AuthProvider } from './contexts/AuthContext'
import { useNavigate } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { Router } from './Router'
type AppStep =
| 'welcome'
@ -20,6 +22,16 @@ type AppStep =
| 'story'
| 'library';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutos
cacheTime: 1000 * 60 * 30, // 30 minutos
refetchOnWindowFocus: false,
},
},
})
export function App() {
const navigate = useNavigate();
const [step, setStep] = useState<AppStep>('welcome');
@ -74,6 +86,7 @@ export function App() {
};
return (
<QueryClientProvider client={queryClient}>
<AuthProvider>
<div className="min-h-screen bg-gradient-to-b from-purple-100 to-blue-100">
{step === 'welcome' && (
@ -112,5 +125,6 @@ export function App() {
)}
</div>
</AuthProvider>
</QueryClientProvider>
);
}

View File

@ -2,112 +2,74 @@ 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, ArrowRight } from 'lucide-react';
const THEMES = [
{
id: 'aventura',
title: 'Aventura',
description: 'Histórias emocionantes com muita ação',
icon: '🗺️'
},
{
id: 'fantasia',
title: 'Fantasia',
description: 'Mundos mágicos e encantados',
icon: '🌟'
}
// ... outros temas
];
interface Category {
id: string;
slug: string;
title: string;
description: string;
icon: string;
}
const SUBJECTS = [
{
id: 'matematica',
title: 'Matemática',
description: 'Números e formas de um jeito divertido',
icon: '🔢'
},
{
id: 'ciencias',
title: 'Ciências',
description: 'Descobertas e experimentos incríveis',
icon: '🔬'
}
// ... outras disciplinas
];
const CHARACTERS = [
{
id: 'explorer',
title: 'Explorador(a)',
description: 'Corajoso(a) e curioso(a)',
icon: '🧭'
},
{
id: 'scientist',
title: 'Cientista',
description: 'Inteligente e criativo(a)',
icon: '👩‍🔬'
}
// ... outros personagens
];
const SETTINGS = [
{
id: 'forest',
title: 'Floresta Mágica',
description: 'Um lugar cheio de mistérios',
icon: '🌳'
},
{
id: 'space',
title: 'Espaço Sideral',
description: 'Aventuras entre as estrelas',
icon: '🚀'
}
// ... outros cenários
];
interface StoryStep {
title: string;
key?: keyof StoryChoices;
items?: Category[];
isContextStep?: boolean;
}
interface StoryChoices {
theme: string | null;
subject: string | null;
character: string | null;
setting: string | null;
theme_id: string | null;
subject_id: string | null;
character_id: string | null;
setting_id: string | null;
context?: string;
}
export function StoryGenerator() {
const navigate = useNavigate();
const { session } = useSession();
const { themes, subjects, characters, settings, isLoading } = useStoryCategories();
const [step, setStep] = React.useState(1);
const [choices, setChoices] = React.useState<StoryChoices>({
theme: null,
subject: null,
character: null,
setting: null
theme_id: null,
subject_id: null,
character_id: null,
setting_id: null,
context: ''
});
const [isGenerating, setIsGenerating] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const [generationStatus, setGenerationStatus] = React.useState<
'idle' | 'creating' | 'generating-images' | 'saving'
>('idle');
const steps = [
const steps: StoryStep[] = [
{
title: 'Escolha o Tema',
items: THEMES,
key: 'theme' as keyof StoryChoices
items: themes || [],
key: 'theme_id'
},
{
title: 'Escolha a Disciplina',
items: SUBJECTS,
key: 'subject' as keyof StoryChoices
items: subjects || [],
key: 'subject_id'
},
{
title: 'Escolha o Personagem',
items: CHARACTERS,
key: 'character' as keyof StoryChoices
items: characters || [],
key: 'character_id'
},
{
title: 'Escolha o Cenário',
items: SETTINGS,
key: 'setting' as keyof StoryChoices
items: settings || [],
key: 'setting_id'
},
{
title: 'Adicione um Contexto (Opcional)',
isContextStep: true
}
];
@ -116,14 +78,15 @@ export function StoryGenerator() {
const handleSelect = (key: keyof StoryChoices, value: string) => {
setChoices(prev => ({ ...prev, [key]: value }));
};
const handleNext = () => {
if (step < steps.length) {
setStep(prev => prev + 1);
}
};
const handleContextChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
setChoices(prev => ({ ...prev, context: event.target.value }));
};
const handleBack = () => {
if (step > 1) {
setStep(prev => prev - 1);
@ -133,16 +96,26 @@ export function StoryGenerator() {
const handleGenerate = async () => {
if (!session?.user?.id) return;
if (!choices.theme_id || !choices.subject_id || !choices.character_id || !choices.setting_id) {
setError('Por favor, preencha todas as escolhas antes de continuar.');
return;
}
try {
setIsGenerating(true);
setError(null);
setGenerationStatus('creating');
const { data: story, error: storyError } = await supabase
.from('stories')
.insert({
student_id: session.user.id,
title: 'Gerando...',
theme: choices.theme,
theme_id: choices.theme_id,
subject_id: choices.subject_id,
character_id: choices.character_id,
setting_id: choices.setting_id,
context: choices.context || null,
status: 'draft',
content: {
prompt: choices,
@ -153,15 +126,66 @@ export function StoryGenerator() {
.single();
if (storyError) throw storyError;
setGenerationStatus('generating-images');
console.log('Chamando Edge Function com:', story);
const { data: functionData, error: functionError } = await supabase.functions
.invoke('generate-story', {
body: { record: story }
});
console.log('Resposta da Edge Function:', functionData);
if (functionError) {
throw new Error(`Erro na Edge Function: ${functionError.message}`);
}
setGenerationStatus('saving');
const { data: updatedStory, error: updateError } = await supabase
.from('stories')
.select('*')
.eq('id', story.id)
.single();
if (updateError) throw updateError;
navigate(`/aluno/historias/${story.id}`);
} catch (err) {
console.error('Erro ao gerar história:', err);
setError('Não foi possível criar sua 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 (isLoading) {
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 */}
@ -180,14 +204,23 @@ export function StoryGenerator() {
{currentStep.title}
</h2>
{/* Cards Grid */}
{currentStep.isContextStep ? (
<div className="space-y-4">
<textarea
value={choices.context}
onChange={handleContextChange}
placeholder="Adicione detalhes ou ideias específicas para sua história..."
className="w-full h-32 p-4 border border-gray-300 rounded-lg focus:ring-purple-500 focus:border-purple-500"
/>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{currentStep.items.map((item) => (
{currentStep.items?.map((item) => (
<button
key={item.id}
onClick={() => handleSelect(currentStep.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
choices[currentStep.key!] === item.id
? 'border-purple-500 bg-purple-50'
: 'border-gray-200 hover:border-purple-200 hover:bg-gray-50'
}`}
@ -202,6 +235,7 @@ export function StoryGenerator() {
</button>
))}
</div>
)}
{error && (
<div className="p-4 bg-red-50 text-red-600 rounded-lg text-sm">
@ -220,23 +254,14 @@ export function StoryGenerator() {
Voltar
</button>
{isLastStep ? (
{isLastStep && (
<button
onClick={handleGenerate}
disabled={!choices.setting || isGenerating}
disabled={!choices.theme_id || !choices.subject_id || !choices.character_id || !choices.setting_id || 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"
>
<Wand2 className="h-5 w-5" />
{isGenerating ? 'Criando história...' : 'Criar História Mágica'}
</button>
) : (
<button
onClick={handleNext}
disabled={!choices[currentStep.key]}
className="flex items-center gap-2 px-4 py-2 text-purple-600 disabled:opacity-50"
>
Próximo
<ArrowRight className="h-5 w-5" />
{isGenerating ? getGenerationStatusText() : 'Criar História Mágica'}
</button>
)}
</div>

View File

@ -0,0 +1,72 @@
import { useQuery } from '@tanstack/react-query';
import { supabase } from '../lib/supabase';
interface Category {
id: string;
slug: string;
title: string;
description: string;
icon: string;
}
export function useStoryCategories() {
const { data: themes, isLoading: loadingThemes } = useQuery({
queryKey: ['story-themes'],
queryFn: async () => {
const { data, error } = await supabase
.from('story_themes')
.select('*')
.order('title');
if (error) throw error;
return data as Category[];
}
});
const { data: subjects, isLoading: loadingSubjects } = useQuery({
queryKey: ['story-subjects'],
queryFn: async () => {
const { data, error } = await supabase
.from('story_subjects')
.select('*')
.order('title');
if (error) throw error;
return data as Category[];
}
});
const { data: characters, isLoading: loadingCharacters } = useQuery({
queryKey: ['story-characters'],
queryFn: async () => {
const { data, error } = await supabase
.from('story_characters')
.select('*')
.order('title');
if (error) throw error;
return data as Category[];
}
});
const { data: settings, isLoading: loadingSettings } = useQuery({
queryKey: ['story-settings'],
queryFn: async () => {
const { data, error } = await supabase
.from('story_settings')
.select('*')
.order('title');
if (error) throw error;
return data as Category[];
}
});
return {
themes,
subjects,
characters,
settings,
isLoading: loadingThemes || loadingSubjects || loadingCharacters || loadingSettings
};
}

View File

@ -1,11 +1,24 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { RouterProvider } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { router } from './routes';
import './index.css';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutos
cacheTime: 1000 * 60 * 30, // 30 minutos
refetchOnWindowFocus: false,
},
},
});
createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</StrictMode>
);

View File

@ -1,67 +1,171 @@
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
import { Configuration, OpenAIApi } from 'https://esm.sh/openai@3.1.0'
import OpenAI from 'https://esm.sh/openai@4.20.1'
const openaiConfig = new Configuration({
const openai = new OpenAI({
apiKey: Deno.env.get('OPENAI_API_KEY')
})
const openai = new OpenAIApi(openaiConfig)
});
interface StoryPrompt {
theme_id: string;
subject_id: string;
character_id: string;
setting_id: string;
context?: string;
}
serve(async (req) => {
const { record } = await req.json()
const prompt = record.content.prompt
try {
const completion = await openai.createChatCompletion({
model: "gpt-4",
// Criar cliente Supabase
const supabase = createClient(
Deno.env.get('SUPABASE_URL') ?? '',
Deno.env.get('SUPABASE_ANON_KEY') ?? ''
)
// Buscar detalhes das categorias selecionadas
const [themeResult, subjectResult, characterResult, settingResult] = await Promise.all([
supabase.from('story_themes').select('*').eq('id', record.theme_id).single(),
supabase.from('story_subjects').select('*').eq('id', record.subject_id).single(),
supabase.from('story_characters').select('*').eq('id', record.character_id).single(),
supabase.from('story_settings').select('*').eq('id', record.setting_id).single()
])
// Log para debug
console.log('Resultados das consultas:', {
theme: themeResult,
subject: subjectResult,
character: characterResult,
setting: settingResult
});
// Verificar erros nas consultas
if (themeResult.error) throw new Error(`Erro ao buscar tema: ${themeResult.error.message}`);
if (subjectResult.error) throw new Error(`Erro ao buscar disciplina: ${subjectResult.error.message}`);
if (characterResult.error) throw new Error(`Erro ao buscar personagem: ${characterResult.error.message}`);
if (settingResult.error) throw new Error(`Erro ao buscar cenário: ${settingResult.error.message}`);
// Verificar se os dados existem
if (!themeResult.data) throw new Error(`Tema não encontrado: ${record.theme_id}`);
if (!subjectResult.data) throw new Error(`Disciplina não encontrada: ${record.subject_id}`);
if (!characterResult.data) throw new Error(`Personagem não encontrado: ${record.character_id}`);
if (!settingResult.data) throw new Error(`Cenário não encontrado: ${record.setting_id}`);
const theme = themeResult.data;
const subject = subjectResult.data;
const character = characterResult.data;
const setting = settingResult.data;
// Log dos dados recebidos
console.log('Record recebido:', record);
console.log('Dados encontrados:', {
theme,
subject,
character,
setting
});
// Construir o prompt para o GPT
const prompt = `
Crie uma história educativa para crianças com as seguintes características:
Tema: ${theme.title}
Disciplina: ${subject.title}
Personagem Principal: ${character.title}
Cenário: ${setting.title}
${record.context ? `Contexto Adicional: ${record.context}` : ''}
Requisitos:
- História adequada para crianças de 6-12 anos
- Conteúdo educativo focado em ${subject.title}
- Linguagem clara e envolvente
- 3-5 páginas de conteúdo
- Cada página deve ter um texto curto e sugestão para uma imagem
- Evitar conteúdo sensível ou inadequado
- Incluir elementos de ${theme.title}
- Ambientado em ${setting.title}
- Personagem principal baseado em ${character.title}
Formato da resposta:
{
"title": "Título da História",
"pages": [
{
"text": "Texto da página",
"image_prompt": "Descrição para gerar a imagem"
}
]
}
`
// Gerar história com GPT-4 Turbo
const completion = await openai.chat.completions.create({
model: "gpt-4o-mini",
messages: [
{
role: "system",
content: `Você é um contador de histórias infantis especializado em criar histórias educativas e envolventes para crianças.
Crie uma história com 3-5 páginas, cada uma com 2-3 parágrafos curtos.
A história deve ser apropriada para a idade e incluir elementos dos interesses da criança.
Use linguagem simples e clara, mas inclua algumas das palavras para prática quando apropriado.`
content: "Você é um contador de histórias infantis especializado em conteúdo educativo."
},
{
role: "user",
content: `Crie uma história com os seguintes elementos:
Interesses: ${prompt.studentInterests.join(', ')}
Personagem Principal: ${prompt.characters.main}
Cenário: ${prompt.setting.place}
Palavras para prática: ${prompt.practiceWords?.join(', ') || 'N/A'}
Características da criança: ${JSON.stringify(prompt.studentCharacteristics)}
Nível de dificuldade: ${prompt.difficulty}`
content: prompt
}
],
temperature: 0.7,
max_tokens: 1000
});
const storyContent = JSON.parse(completion.choices[0].message.content || '{}');
// Gerar imagens com DALL-E
const pages = await Promise.all(
storyContent.pages.map(async (page: any) => {
const imageResponse = await openai.images.generate({
prompt: `${page.image_prompt}. Style: children's book illustration, colorful, educational, safe for kids`,
n: 1,
size: "1024x1024"
});
return {
text: page.text,
image: imageResponse.data[0].url
}
]
})
);
const story = completion.data.choices[0].message?.content
if (!story) throw new Error('Falha ao gerar história')
// Processar a história em páginas
const pages = story.split('\n\n').map(text => ({ text }))
// Atualizar o registro no banco
const supabase = createClient(
Deno.env.get('SUPABASE_URL') ?? '',
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''
)
// Atualizar história no Supabase
await supabase
.from('stories')
.update({
content: { pages },
title: storyContent.title,
content: {
title: storyContent.title,
pages: pages,
theme: theme.title,
subject: subject.title,
character: character.title,
setting: setting.title,
context: record.context
},
status: 'published'
})
.eq('id', record.id)
return new Response(JSON.stringify({ success: true }), {
headers: { 'Content-Type': 'application/json' }
})
return new Response(
JSON.stringify({ success: true }),
{ headers: { 'Content-Type': 'application/json' } }
)
} catch (error) {
return new Response(JSON.stringify({ error: error.message }), {
console.error('Erro ao gerar história:', error)
return new Response(
JSON.stringify({ error: error.message }),
{
headers: { 'Content-Type': 'application/json' },
status: 500
})
}
)
}
})