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

- Adiciona fluxo de criação em etapas com cards
- Implementa Edge Function para geração via GPT-4
- Cria interfaces e tipos para o gerador de histórias
- Adiciona seleção de tema, disciplina, personagem e cenário
- Integra com Supabase para armazenamento e processamento
- Melhora UX com feedback visual e navegação intuitiva
This commit is contained in:
Lucas Santana 2024-12-22 16:42:39 -03:00
parent 1a3a603ff6
commit 0b8c050bd7
8 changed files with 428 additions and 155 deletions

17
package-lock.json generated
View File

@ -15,6 +15,7 @@
"lucide-react": "^0.344.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.54.2",
"react-router-dom": "^6.28.0",
"resend": "^3.2.0",
"tailwind-merge": "^2.5.5"
@ -4243,6 +4244,22 @@
"react": "^18.3.1"
}
},
"node_modules/react-hook-form": {
"version": "7.54.2",
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.54.2.tgz",
"integrity": "sha512-eHpAUgUjWbZocoQYUHposymRb4ZP6d0uwUnooL2uOybA9/3tPUvoAKqEWK1WaSiTxxOfTpffNZP7QwlnM3/gEg==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/react-hook-form"
},
"peerDependencies": {
"react": "^16.8.0 || ^17 || ^18 || ^19"
}
},
"node_modules/react-promise-suspense": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/react-promise-suspense/-/react-promise-suspense-0.3.4.tgz",

View File

@ -19,6 +19,7 @@
"lucide-react": "^0.344.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.54.2",
"react-router-dom": "^6.28.0",
"resend": "^3.2.0",
"tailwind-merge": "^2.5.5"

View File

@ -0,0 +1,245 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { supabase } from '../../lib/supabase';
import { useSession } from '../../hooks/useSession';
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
];
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 StoryChoices {
theme: string | null;
subject: string | null;
character: string | null;
setting: string | null;
}
export function StoryGenerator() {
const navigate = useNavigate();
const { session } = useSession();
const [step, setStep] = React.useState(1);
const [choices, setChoices] = React.useState<StoryChoices>({
theme: null,
subject: null,
character: null,
setting: null
});
const [isGenerating, setIsGenerating] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const steps = [
{
title: 'Escolha o Tema',
items: THEMES,
key: 'theme' as keyof StoryChoices
},
{
title: 'Escolha a Disciplina',
items: SUBJECTS,
key: 'subject' as keyof StoryChoices
},
{
title: 'Escolha o Personagem',
items: CHARACTERS,
key: 'character' as keyof StoryChoices
},
{
title: 'Escolha o Cenário',
items: SETTINGS,
key: 'setting' as keyof StoryChoices
}
];
const currentStep = steps[step - 1];
const isLastStep = step === steps.length;
const handleSelect = (key: keyof StoryChoices, value: string) => {
setChoices(prev => ({ ...prev, [key]: value }));
};
const handleNext = () => {
if (step < steps.length) {
setStep(prev => prev + 1);
}
};
const handleBack = () => {
if (step > 1) {
setStep(prev => prev - 1);
}
};
const handleGenerate = async () => {
if (!session?.user?.id) return;
try {
setIsGenerating(true);
setError(null);
const { data: story, error: storyError } = await supabase
.from('stories')
.insert({
student_id: session.user.id,
title: 'Gerando...',
theme: choices.theme,
status: 'generating',
content: {
prompt: choices,
pages: []
}
})
.select()
.single();
if (storyError) throw storyError;
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);
}
};
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>
{/* Cards Grid */}
<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>
)}
{/* Navigation Buttons */}
<div className="flex justify-between pt-6">
<button
onClick={handleBack}
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>
{isLastStep ? (
<button
onClick={handleGenerate}
disabled={!choices.setting || 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" />
</button>
)}
</div>
</div>
);
}

28
src/hooks/useSession.ts Normal file
View File

@ -0,0 +1,28 @@
import { useEffect, useState } from 'react';
import { Session } from '@supabase/supabase-js';
import { supabase } from '../lib/supabase';
export function useSession() {
const [session, setSession] = useState<Session | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
// Pega a sessão atual
supabase.auth.getSession().then(({ data: { session } }) => {
setSession(session);
setLoading(false);
});
// Escuta mudanças na autenticação
const {
data: { subscription },
} = supabase.auth.onAuthStateChange((_event, session) => {
setSession(session);
setLoading(false);
});
return () => subscription.unsubscribe();
}, []);
return { session, loading };
}

View File

@ -1,7 +1,7 @@
import React, { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { supabase } from '../../lib/supabase';
import { Story } from '../../types/database';
import { Story, StoryRecording } from '../../types/database';
import { AudioRecorder } from '../../components/story/AudioRecorder';
import { Loader2 } from 'lucide-react';
import type { MetricsData } from '../../components/story/StoryMetrics';

View File

@ -1,83 +1,27 @@
import React from 'react';
import { ArrowLeft, Sparkles, Send } from 'lucide-react';
import { ArrowLeft, Sparkles } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { supabase } from '../../lib/supabase';
interface StoryForm {
title: string;
theme: string;
prompt: string;
}
import { StoryGenerator } from '../../components/story/StoryGenerator';
import { useSession } from '../../hooks/useSession';
export function CreateStoryPage() {
const navigate = useNavigate();
const [formData, setFormData] = React.useState<StoryForm>({
title: '',
theme: '',
prompt: ''
});
const [generating, setGenerating] = React.useState(false);
const { session } = useSession();
const [error, setError] = React.useState<string | null>(null);
const themes = [
{ id: 'nature', name: 'Natureza e Meio Ambiente' },
{ id: 'culture', name: 'Cultura Brasileira' },
{ id: 'science', name: 'Ciência e Descobertas' },
{ id: 'adventure', name: 'Aventura e Exploração' },
{ id: 'friendship', name: 'Amizade e Cooperação' }
];
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setGenerating(true);
setError(null);
try {
const { data: { session } } = await supabase.auth.getSession();
if (!session?.user?.id) throw new Error('Usuário não autenticado');
// Em produção: Integrar com API de IA para gerar história
const generatedStory = {
title: formData.title,
content: {
pages: [
{
text: "Era uma vez...",
image: "https://images.unsplash.com/photo-1472162072942-cd5147eb3902"
if (!session) {
return (
<div className="text-center py-12">
<p className="text-gray-600">Você precisa estar logado para criar histórias.</p>
<button
onClick={() => navigate('/login')}
className="mt-4 text-purple-600 hover:text-purple-700"
>
Fazer login
</button>
</div>
);
}
]
},
theme: formData.theme,
status: 'draft'
};
const { error: saveError } = await supabase
.from('stories')
.insert({
student_id: session.user.id,
title: generatedStory.title,
theme: generatedStory.theme,
content: generatedStory.content,
status: generatedStory.status,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
});
if (saveError) throw saveError;
navigate('/aluno/historias');
} catch (err) {
console.error('Erro ao criar história:', err);
setError('Não foi possível criar sua história. Tente novamente.');
} finally {
setGenerating(false);
}
};
return (
<div>
@ -90,7 +34,17 @@ export function CreateStoryPage() {
</button>
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h1 className="text-2xl font-bold text-gray-900 mb-6">Criar Nova História</h1>
<div className="flex items-center gap-3 mb-8">
<div className="p-2 bg-purple-100 rounded-lg">
<Sparkles className="h-6 w-6 text-purple-600" />
</div>
<div>
<h1 className="text-2xl font-bold text-gray-900">Criar Nova História</h1>
<p className="text-gray-600">
Vamos criar uma história personalizada baseada nos seus interesses
</p>
</div>
</div>
{error && (
<div className="mb-6 p-4 bg-red-50 text-red-600 rounded-lg">
@ -98,88 +52,20 @@ export function CreateStoryPage() {
</div>
)}
<form onSubmit={handleSubmit} className="space-y-6 max-w-2xl">
<div>
<label htmlFor="title" className="block text-sm font-medium text-gray-700 mb-1">
Título da História
</label>
<input
type="text"
id="title"
name="title"
value={formData.title}
onChange={handleChange}
className="w-full rounded-lg border-gray-300 focus:ring-purple-500 focus:border-purple-500"
required
placeholder="Ex: A Aventura na Floresta Encantada"
/>
</div>
<StoryGenerator />
<div>
<label htmlFor="theme" className="block text-sm font-medium text-gray-700 mb-1">
Tema
</label>
<select
id="theme"
name="theme"
value={formData.theme}
onChange={handleChange}
className="w-full rounded-lg border-gray-300 focus:ring-purple-500 focus:border-purple-500"
required
>
<option value="">Selecione um tema</option>
{themes.map(theme => (
<option key={theme.id} value={theme.id}>
{theme.name}
</option>
))}
</select>
</div>
<div>
<label htmlFor="prompt" className="block text-sm font-medium text-gray-700 mb-1">
Sobre o que você quer escrever?
</label>
<textarea
id="prompt"
name="prompt"
value={formData.prompt}
onChange={handleChange}
rows={4}
className="w-full rounded-lg border-gray-300 focus:ring-purple-500 focus:border-purple-500"
required
placeholder="Descreva sua ideia para a história..."
/>
</div>
<div className="bg-purple-50 rounded-lg p-4">
<div className="flex items-start gap-3">
<div className="p-2 bg-purple-100 rounded-lg">
<Sparkles className="h-5 w-5 text-purple-600" />
</div>
<div>
<h3 className="text-sm font-medium text-purple-900">
Assistente de Criação
<div className="mt-8 p-4 bg-purple-50 rounded-lg">
<h3 className="text-sm font-medium text-purple-900 mb-2">
Como funciona?
</h3>
<p className="text-sm text-purple-700">
Nossa IA vai ajudar você a criar uma história incrível baseada nas suas ideias!
</p>
<ol className="text-sm text-purple-700 space-y-2">
<li>1. Conte-nos sobre seus interesses e preferências</li>
<li>2. Escolha personagens e cenários para sua história</li>
<li>3. Nossa IA criará uma história única e personalizada</li>
<li>4. Você poderá ler e praticar com sua nova história</li>
</ol>
</div>
</div>
</div>
<div className="flex justify-end pt-6">
<button
type="submit"
disabled={generating}
className="flex items-center gap-2 px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition disabled:opacity-50"
>
<Send className="h-5 w-5" />
{generating ? 'Criando História...' : 'Criar História'}
</button>
</div>
</form>
</div>
</div>
);
}

View File

@ -0,0 +1,29 @@
export interface StoryPrompt {
studentInterests: string[];
characters: {
main: string;
supporting?: string[];
};
setting: {
place: string;
time?: string;
};
practiceWords?: string[];
studentCharacteristics?: {
age?: number;
gender?: string;
personalityTraits?: string[];
};
theme?: string;
difficulty?: 'easy' | 'medium' | 'hard';
}
export interface GeneratedStory {
title: string;
content: {
pages: {
text: string;
image?: string;
}[];
};
}

View File

@ -0,0 +1,67 @@
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'
const openaiConfig = new Configuration({
apiKey: Deno.env.get('OPENAI_API_KEY')
})
const openai = new OpenAIApi(openaiConfig)
serve(async (req) => {
const { record } = await req.json()
const prompt = record.content.prompt
try {
const completion = await openai.createChatCompletion({
model: "gpt-4",
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.`
},
{
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}`
}
]
})
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') ?? ''
)
await supabase
.from('stories')
.update({
content: { pages },
status: 'published'
})
.eq('id', record.id)
return new Response(JSON.stringify({ success: true }), {
headers: { 'Content-Type': 'application/json' }
})
} catch (error) {
return new Response(JSON.stringify({ error: error.message }), {
headers: { 'Content-Type': 'application/json' },
status: 500
})
}
})