

Arquitetura completa de uma aplicação de IA com streaming SSE, autenticação, banco vetorial e deploy. O que aprendi construindo a MSc Academy.
Construir uma aplicação de IA completa — do frontend ao deploy — envolve muito mais do que integrar uma API de LLM. Neste artigo, compartilho a arquitetura que uso na MSc Academy e as decisões de design que fiz ao longo do caminho.
Frontend: React 19 + Tailwind CSS 4 + Framer Motion
Backend: Node.js + Express + tRPC (MSc Academy)
Python + FastAPI (serviços de IA)
LLM: LangChain + LangGraph + Claude/GPT-4
DB: MySQL/TiDB + PGvector (Supabase)
Deploy: Manus (frontend+backend) + Docker
Frontend: React 19 + Tailwind CSS 4 + Framer Motion
Backend: Node.js + Express + tRPC (MSc Academy)
Python + FastAPI (serviços de IA)
LLM: LangChain + LangGraph + Claude/GPT-4
DB: MySQL/TiDB + PGvector (Supabase)
Deploy: Manus (frontend+backend) + Docker
A experiência de ver o texto aparecer palavra por palavra é fundamental para aplicações de IA. Implemento isso com Server-Sent Events (SSE):
app.get("/api/chat/stream", async (req, res) => {
// Configura SSE
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
res.setHeader("Access-Control-Allow-Origin", "*");
const { message, sessionId } = req.query;
try {
// Stream do LLM token por token
const stream = await llm.stream([
{ role: "system", content: SYSTEM_PROMPT },
...await getHistory(sessionId),
{ role: "user", content: message },
]);
for await (const chunk of stream) {
const token = chunk.choices[0]?.delta?.content || "";
if (token) {
res.write(`data: ${JSON.stringify({ token })}\n\n`);
}
}
res.write("data: [DONE]\n\n");
} catch (error) {
res.write(`data: ${JSON.stringify({ error: "Erro interno" })}\n\n`);
} finally {
res.end();
}
});
app.get("/api/chat/stream", async (req, res) => {
// Configura SSE
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
res.setHeader("Access-Control-Allow-Origin", "*");
const { message, sessionId } = req.query;
try {
// Stream do LLM token por token
const stream = await llm.stream([
{ role: "system", content: SYSTEM_PROMPT },
...await getHistory(sessionId),
{ role: "user", content: message },
]);
for await (const chunk of stream) {
const token = chunk.choices[0]?.delta?.content || "";
if (token) {
res.write(`data: ${JSON.stringify({ token })}\n\n`);
}
}
res.write("data: [DONE]\n\n");
} catch (error) {
res.write(`data: ${JSON.stringify({ error: "Erro interno" })}\n\n`);
} finally {
res.end();
}
});
function useStreamingChat() {
const [streamingText, setStreamingText] = useState("");
const [isStreaming, setIsStreaming] = useState(false);
const sendMessage = useCallback(async (message: string) => {
setIsStreaming(true);
setStreamingText("");
const eventSource = new EventSource(
`/api/chat/stream?message=${encodeURIComponent(message)}&sessionId=${sessionId}`
);
eventSource.onmessage = (event) => {
if (event.data === "[DONE]") {
eventSource.close();
setIsStreaming(false);
return;
}
const { token } = JSON.parse(event.data);
setStreamingText(prev => prev + token);
};
eventSource.onerror = () => {
eventSource.close();
setIsStreaming(false);
};
}, [sessionId]);
return { streamingText, isStreaming, sendMessage };
}
function useStreamingChat() {
const [streamingText, setStreamingText] = useState("");
const [isStreaming, setIsStreaming] = useState(false);
const sendMessage = useCallback(async (message: string) => {
setIsStreaming(true);
setStreamingText("");
const eventSource = new EventSource(
`/api/chat/stream?message=${encodeURIComponent(message)}&sessionId=${sessionId}`
);
eventSource.onmessage = (event) => {
if (event.data === "[DONE]") {
eventSource.close();
setIsStreaming(false);
return;
}
const { token } = JSON.parse(event.data);
setStreamingText(prev => prev + token);
};
eventSource.onerror = () => {
eventSource.close();
setIsStreaming(false);
};
}, [sessionId]);
return { streamingText, isStreaming, sendMessage };
}
Para a MSc Academy, optei por autenticação simples com JWT em vez de OAuth para simplificar o onboarding:
// Usuários hardcoded para demo + registro de novos usuários
const DEMO_USERS = {
admin: { password: "admin", role: "admin" },
Moises: { password: "admin", role: "admin" },
};
app.post("/api/auth/login", async (req, res) => {
const { username, password } = req.body;
// Verifica usuários demo
const demoUser = DEMO_USERS[username];
if (demoUser && demoUser.password === password) {
const token = await createJWT({ username, role: demoUser.role });
res.cookie("session", token, { httpOnly: true, maxAge: 365 * 24 * 60 * 60 * 1000 });
return res.json({ success: true });
}
// Verifica usuários registrados no banco
const dbUser = await db.getUserByUsername(username);
if (dbUser && await bcrypt.compare(password, dbUser.passwordHash)) {
const token = await createJWT({ username, role: dbUser.role });
res.cookie("session", token, { httpOnly: true });
return res.json({ success: true });
}
return res.status(401).json({ error: "Credenciais inválidas" });
});
// Usuários hardcoded para demo + registro de novos usuários
const DEMO_USERS = {
admin: { password: "admin", role: "admin" },
Moises: { password: "admin", role: "admin" },
};
app.post("/api/auth/login", async (req, res) => {
const { username, password } = req.body;
// Verifica usuários demo
const demoUser = DEMO_USERS[username];
if (demoUser && demoUser.password === password) {
const token = await createJWT({ username, role: demoUser.role });
res.cookie("session", token, { httpOnly: true, maxAge: 365 * 24 * 60 * 60 * 1000 });
return res.json({ success: true });
}
// Verifica usuários registrados no banco
const dbUser = await db.getUserByUsername(username);
if (dbUser && await bcrypt.compare(password, dbUser.passwordHash)) {
const token = await createJWT({ username, role: dbUser.role });
res.cookie("session", token, { httpOnly: true });
return res.json({ success: true });
}
return res.status(401).json({ error: "Credenciais inválidas" });
});
Uma das melhores decisões que tomei foi usar tRPC. Ele elimina toda a camada de serialização/deserialização:
// server/routers.ts
export const appRouter = router({
topics: router({
list: publicProcedure.query(async () => {
return await db.getAllTopics();
}),
getBySlug: publicProcedure
.input(z.object({ slug: z.string() }))
.query(async ({ input }) => {
return await db.getTopicBySlug(input.slug);
}),
}),
exercises: router({
verify: protectedProcedure
.input(z.object({
exerciseId: z.number(),
answer: z.string(),
}))
.mutation(async ({ input, ctx }) => {
const exercise = await db.getExercise(input.exerciseId);
const isCorrect = checkAnswer(exercise, input.answer);
if (isCorrect) {
await db.recordProgress(ctx.user.id, input.exerciseId);
}
return { isCorrect, explanation: exercise.explanation };
}),
}),
});
// client/src/pages/Exercises.tsx
const { data: exercises } = trpc.exercises.list.useQuery({ topicId });
const verifyMutation = trpc.exercises.verify.useMutation({
onSuccess: (data) => {
if (data.isCorrect) toast.success("Correto! 🎉");
else toast.error(`Incorreto. ${data.explanation}`);
},
});
// server/routers.ts
export const appRouter = router({
topics: router({
list: publicProcedure.query(async () => {
return await db.getAllTopics();
}),
getBySlug: publicProcedure
.input(z.object({ slug: z.string() }))
.query(async ({ input }) => {
return await db.getTopicBySlug(input.slug);
}),
}),
exercises: router({
verify: protectedProcedure
.input(z.object({
exerciseId: z.number(),
answer: z.string(),
}))
.mutation(async ({ input, ctx }) => {
const exercise = await db.getExercise(input.exerciseId);
const isCorrect = checkAnswer(exercise, input.answer);
if (isCorrect) {
await db.recordProgress(ctx.user.id, input.exerciseId);
}
return { isCorrect, explanation: exercise.explanation };
}),
}),
});
// client/src/pages/Exercises.tsx
const { data: exercises } = trpc.exercises.list.useQuery({ topicId });
const verifyMutation = trpc.exercises.verify.useMutation({
onSuccess: (data) => {
if (data.isCorrect) toast.success("Correto! 🎉");
else toast.error(`Incorreto. ${data.explanation}`);
},
});
Para o avatar do Arquimedes, criei um sistema de estados visuais que responde ao estado da conversa:
type AvatarState = "idle" | "thinking" | "speaking" | "celebrating";
function ArquimedesAvatar({ state }: { state: AvatarState }) {
const animations = {
idle: { y: [0, -8, 0], transition: { repeat: Infinity, duration: 3 } },
thinking: { rotate: [-2, 2, -2], transition: { repeat: Infinity, duration: 0.5 } },
speaking: { scale: [1, 1.02, 1], transition: { repeat: Infinity, duration: 0.3 } },
celebrating: { y: [0, -20, 0], rotate: [0, 10, -10, 0], transition: { duration: 0.6 } },
};
return (
<motion.div animate={animations[state]}>
<img src="/manus-storage/Arquimedes_f63227f7.webp" alt="Arquimedes" />
</motion.div>
);
}
type AvatarState = "idle" | "thinking" | "speaking" | "celebrating";
function ArquimedesAvatar({ state }: { state: AvatarState }) {
const animations = {
idle: { y: [0, -8, 0], transition: { repeat: Infinity, duration: 3 } },
thinking: { rotate: [-2, 2, -2], transition: { repeat: Infinity, duration: 0.5 } },
speaking: { scale: [1, 1.02, 1], transition: { repeat: Infinity, duration: 0.3 } },
celebrating: { y: [0, -20, 0], rotate: [0, 10, -10, 0], transition: { duration: 0.6 } },
};
return (
<motion.div animate={animations[state]}>
<img src="/manus-storage/Arquimedes_f63227f7.webp" alt="Arquimedes" />
</motion.div>
);
}
Para o deploy da MSc Academy, uso a plataforma Manus que oferece:
Para otimização de performance:
// Lazy loading de páginas pesadas
const Chat = lazy(() => import("./pages/Chat"));
const Topics = lazy(() => import("./pages/Topics"));
// Prefetch de dados críticos
queryClient.prefetchQuery({
queryKey: ["topics"],
queryFn: () => trpc.topics.list.query(),
});
// Lazy loading de páginas pesadas
const Chat = lazy(() => import("./pages/Chat"));
const Topics = lazy(() => import("./pages/Topics"));
// Prefetch de dados críticos
queryClient.prefetchQuery({
queryKey: ["topics"],
queryFn: () => trpc.topics.list.query(),
});
1. Comece com SSE, não WebSockets: SSE é mais simples, funciona com HTTP/2 e é suficiente para streaming unidirecional.
2. tRPC vale o investimento: a type safety end-to-end economiza horas de debugging.
3. Otimize o bundle desde o início: lazy loading e code splitting são muito mais difíceis de adicionar depois.
4. Monitore o custo de tokens: em produção, o custo de LLM pode surpreender. Implemente rate limiting e caching de respostas comuns.
5. UX de loading é tão importante quanto a resposta: spinners, skeletons e streaming text fazem a diferença na percepção de velocidade.
Construir aplicações Full Stack AI é um campo em rápida evolução. A stack que uso hoje — React + tRPC + LangChain + SSE — é o resultado de muitas iterações e erros. O mais importante é começar, iterar rápido e medir o impacto real no usuário.
Moises Costa é AI Engineer e criador da MSc Academy. Veja o código no GitHub.