Część 1: Jak zbudować chatbota z T3 Turbo i Gemini
Większość founderów utyka w "piekle konfiguracji". Ja w jedno popołudnie zbudowałem w pełni otypowanego chatbota AI. Oto dokładny stack – Next.js, tRPC i Gemini – oraz kod, dzięki któremu zrobisz to samodzielnie.

Oto tłumaczenie artykułu, zachowujące oryginalny styl, formatowanie i techniczny charakter wpisu.
Złożoność to cichy zabójca startupów na wczesnym etapie. Zaczynasz z prostym pomysłem – „Chcę chatbota, który gada jak Tony Stark” – a trzy tygodnie później wciąż konfigurujesz Webpacka, walczysz z kontenerami Dockera albo debugujesz proces uwierzytelniania, którego nikt jeszcze nie użył.
To pułapka, w którą widziałem wpadających genialnych inżynierów raz za razem. Kochamy nasze narzędzia. Kochamy optymalizację. Ale w grze zwanej startupem, dowiezienie produktu (shipping) to jedyna metryka, która się liczy.
Jeśli ostatnio nie zaglądałeś do nowoczesnego ekosystemu TypeScript, możesz być zaskoczony. Czasy zszywania ze sobą oddzielnych API i modlenia się, żeby to wszystko się nie posypało, mamy już w dużej mierze za sobą. Wkroczyliśmy w erę „Vibe Codera” – gdzie dystans między pomysłem a wdrożonym produktem mierzy się w godzinach, a nie w sprintach.
Dzisiaj przeprowadzę cię przez stack, który wydaje się być kodem na nieśmiertelność: Create T3 Turbo połączony z Google Gemini AI. Jest bezpieczny typowo (type-safe) od bazy danych aż po frontend, jest absurdalnie szybki i szczerze mówiąc – przywraca radość z kodowania.
Dlaczego ten stack ma znaczenie?
Możesz pomyśleć: „Feng, po co kolejny stack? Nie mogę po prostu użyć Pythona i Streamlit?”
Jasne, do prototypu. Ale jeśli budujesz produkt – coś, co musi się skalować, obsługiwać użytkowników i utrzymywać stan – potrzebujesz prawdziwej architektury. Problem w tym, że „prawdziwa architektura” zazwyczaj oznacza „tygodnie pisania boilerplate'u”.
T3 Stack (Next.js, tRPC, Tailwind) odwraca ten scenariusz. Daje ci solidność aplikacji full-stack z szybkością tworzenia prostego skryptu. Kiedy dodasz do tego Drizzle ORM (lekki, SQL-owy) i Google Gemini (szybki, z hojnym darmowym planem), otrzymujesz zestaw narzędzi, który pozwala samotnemu founderowi wymanewrować dziesięcioosobowy zespół.
Zbudujmy coś prawdziwego.
Krok 1: Konfiguracja jedną komendą
Zapomnij o ręcznym konfigurowaniu ESLinta i Prettiera. Użyjemy create-t3-turbo. To ustawi nam strukturę monorepo, co jest idealne, ponieważ oddziela logikę API od frontendu w Next.js, zabezpieczając cię na przyszłość, gdy nieuchronnie będziesz chciał później wypuścić aplikację mobilną w React Native.
pnpm create t3-turbo@latest my-chatbot
cd my-chatbot
pnpm install
Gdy zapytano, wybrałem Next.js, tRPC i PostgreSQL. Pominąłem na razie Auth, ponieważ – przypominam – optymalizujemy pod kątem dowiezienia produktu, a nie perfekcji. NextAuth możesz dodać później w dziesięć minut.
Struktura monorepo, którą otrzymujesz:
my-chatbot/
├── apps/nextjs/ # Twoja aplikacja webowa
├── packages/
│ ├── api/ # Routery tRPC (współdzielona logika)
│ ├── db/ # Schemat bazy danych + Drizzle
│ └── ui/ # Współdzielone komponenty
Ta separacja oznacza, że twoja logika API może być ponownie wykorzystana w aplikacjach webowych, mobilnych, a nawet w CLI. Widziałem zespoły marnujące miesiące na refaktoryzację, bo zaczęli ze wszystkim w jednym folderze.
Krok 2: Mózg (Gemini)
OpenAI jest świetne, ale czy próbowałeś Gemini Flash? Jest niesamowicie szybki, a ceny są agresywne. W przypadku interfejsu czatu, gdzie opóźnienia zabijają „vibe”, szybkość jest funkcją (feature).
Dlaczego Gemini Flash zamiast GPT-3.5/4?
- Szybkość: ~800ms vs 2-3s czasu odpowiedzi
- Koszt: 60x tańszy niż GPT-4
- Kontekst: Okno kontekstowe 1M tokenów (tak, jeden milion)
Potrzebujemy AI SDK, aby ustandaryzować rozmowę z modelami LLM.
cd packages/api
pnpm add ai @ai-sdk/google
Ustaw swój plik .env w katalogu głównym projektu. Nie przekombinuj z bazą danych lokalnie; lokalna instancja Postgresa w zupełności wystarczy.
POSTGRES_URL="postgresql://user:pass@localhost:5432/chatbot"
GOOGLE_GENERATIVE_AI_API_KEY="your_key_here"
Pro tip: Pobierz swój klucz API Gemini z https://aistudio.google.com/app/apikey. Darmowy plan jest absurdalnie hojny – 60 zapytań na minutę. Osiągniesz Product-Market Fit, zanim dobijesz do limitów (rate limits).
Krok 3: Definiowanie Rzeczywistości (Schemat)
Tutaj Drizzle błyszczy. W dawnych czasach pisałeś migracje ręcznie. Teraz definiujesz swój schemat w TypeScript, a baza danych się słucha.
W packages/db/src/schema.ts definiujemy, czym jest „Wiadomość” (Message). Zauważ, jak używamy drizzle-zod. To automatycznie tworzy schematy walidacji dla naszego API. To zasada „Don't Repeat Yourself” w akcji.
import { pgTable } from "drizzle-orm/pg-core";
import { createInsertSchema } from "drizzle-zod";
import { z } from "zod/v4";
// Message table for chatbot
export const Message = pgTable("message", (t) => ({
id: t.integer().primaryKey().generatedAlwaysAsIdentity(),
role: t.varchar({ length: 20 }).notNull(), // 'user' or 'assistant'
content: t.text().notNull(),
createdAt: t.timestamp().defaultNow().notNull(),
}));
// Zod schema auto-generated from table definition
export const CreateMessageSchema = createInsertSchema(Message, {
role: z.enum(["user", "assistant"]),
content: z.string().min(1).max(10000),
}).omit({ id: true, createdAt: true });
Wypchnij to: pnpm db:push. Gotowe. Twoja baza danych teraz istnieje.
Co się właśnie stało? Drizzle spojrzał na twoją definicję TypeScript i stworzył tabelę. Zero napisanego SQL-a. Żadnych plików migracji. To jest magia rozwoju opartego na schematach (schema-driven development).
Jeśli chcesz to zweryfikować, uruchom: pnpm db:studio, a zobaczysz interfejs webowy pod adresem https://local.drizzle.studio z twoją tabelą message, gotową na przyjęcie danych.
Krok 4: Układ Nerwowy (tRPC)
To jest część, która zazwyczaj rozwala ludziom mózgi. W REST lub GraphQL musisz definiować endpointy, typy i fetchery oddzielnie. W tRPC twoja funkcja backendowa jest twoją funkcją frontendową.
Tworzymy procedurę, która zapisuje wiadomość użytkownika, pobiera historię (kontekst to król w AI), wysyła ją do Gemini i zapisuje odpowiedź.
Utwórz packages/api/src/router/chat.ts:
import type { TRPCRouterRecord } from "@trpc/server";
import { google } from "@ai-sdk/google";
import { generateText } from "ai";
import { z } from "zod/v4";
import { desc } from "@acme/db";
import { Message } from "@acme/db/schema";
import { publicProcedure } from "../trpc";
const SYSTEM_PROMPT = "You are a helpful AI assistant.";
export const chatRouter = {
sendChat: publicProcedure
.input(z.object({ content: z.string().min(1).max(10000) }))
.mutation(async ({ ctx, input }) => {
// 1. Save User Message
await ctx.db
.insert(Message)
.values({ role: "user", content: input.content });
// 2. Get Context (Last 10 messages)
const history = await ctx.db
.select()
.from(Message)
.orderBy(desc(Message.createdAt))
.limit(10);
// 3. Ask Gemini
const { text } = await generateText({
model: google("gemini-1.5-flash"),
system: SYSTEM_PROMPT,
messages: history.reverse().map((m) => ({
role: m.role as "user" | "assistant",
content: m.content,
})),
});
// 4. Save AI Reply
return await ctx.db
.insert(Message)
.values({ role: "assistant", content: text })
.returning();
}),
getMessages: publicProcedure.query(({ ctx }) =>
ctx.db.select().from(Message).orderBy(Message.createdAt),
),
clearMessages: publicProcedure.mutation(({ ctx }) => ctx.db.delete(Message)),
} satisfies TRPCRouterRecord;
Zarejestruj router w packages/api/src/root.ts:
import { chatRouter } from "./router/chat";
import { createTRPCRouter } from "./trpc";
export const appRouter = createTRPCRouter({
chat: chatRouter,
});
export type AppRouter = typeof appRouter;
Spójrz na ten przepływ (flow). Jest liniowy, czytelny i w pełni otypowany. Jeśli zmienisz schemat bazy danych, ten kod natychmiast zaświeci się na czerwono. Żadnych niespodzianek w czasie działania (runtime).
Dlaczego .reverse()? Odpytujemy o wiadomości w kolejności malejącej (najnowsze pierwsze), ale LLM-y oczekują kolejności chronologicznej (najstarsze pierwsze). To drobny szczegół, który zapobiega mylącym konwersacjom.

Krok 5: Interfejs
W apps/nextjs/src/app/chat/page.tsx spinamy to wszystko. Ponieważ używamy tRPC, dostajemy React Query za darmo. useQuery obsługuje pobieranie, cache'owanie i stany ładowania bez pisania ani jednego useEffect do fetchowania danych.
(Dodałem useEffect tylko do przewijania na dół – bo UX ma znaczenie).
"use client";
import { useEffect, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { RouterOutputs } from "@acme/api";
import { useTRPC } from "~/trpc/react";
export default function ChatPage() {
const [input, setInput] = useState("");
const [loading, setLoading] = useState(false);
const endRef = useRef<HTMLDivElement>(null);
const trpc = useTRPC();
const queryClient = useQueryClient();
// Automatic data fetching with caching
const { data: messages } = useQuery(trpc.chat.getMessages.queryOptions());
// Mutation with optimistic updates
const sendMsg = useMutation(
trpc.chat.sendChat.mutationOptions({
onSuccess: async () => {
await queryClient.invalidateQueries(trpc.chat.pathFilter());
setInput("");
setLoading(false);
},
onError: (err) => {
console.error("Failed:", err);
setLoading(false);
},
}),
);
// Auto-scroll to latest message
useEffect(() => {
endRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!input.trim() || loading) return;
setLoading(true);
sendMsg.mutate({ content: input.trim() });
};
return (
<div className="flex h-screen flex-col bg-gray-50">
{/* Header */}
<div className="border-b bg-white p-4">
<h1 className="text-xl font-bold">AI Chat</h1>
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto p-4">
<div className="mx-auto max-w-4xl space-y-4">
{messages?.map((m: RouterOutputs["chat"]["getMessages"][number]) => (
<div key={m.id} className={m.role === "user" ? "text-right" : ""}>
<div
className={`inline-block rounded-2xl px-4 py-3 ${
m.role === "user"
? "bg-blue-500 text-white"
: "bg-white border shadow-sm"
}`}
>
<p className="whitespace-pre-wrap">{m.content}</p>
</div>
</div>
))}
{loading && (
<div className="flex gap-2">
<div className="h-2 w-2 animate-bounce rounded-full bg-gray-400" />
<div className="h-2 w-2 animate-bounce rounded-full bg-gray-400 [animation-delay:0.2s]" />
<div className="h-2 w-2 animate-bounce rounded-full bg-gray-400 [animation-delay:0.4s]" />
</div>
)}
<div ref={endRef} />
</div>
</div>
{/* Input */}
<form onSubmit={handleSubmit} className="border-t bg-white p-4">
<div className="mx-auto flex max-w-4xl gap-2">
<input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Type your message..."
className="flex-1 rounded-lg border px-4 py-3 focus:ring-2 focus:ring-blue-500 focus:outline-none"
disabled={loading}
/>
<button
type="submit"
disabled={!input.trim() || loading}
className="rounded-lg bg-blue-500 px-6 py-3 font-medium text-white hover:bg-blue-600 disabled:bg-gray-300 disabled:cursor-not-allowed"
>
Send
</button>
</div>
</form>
</div>
);
}
Nie zapomnij o stronie głównej. Zaktualizuj apps/nextjs/src/app/page.tsx:
import Link from "next/link";
export default function HomePage() {
return (
<main className="flex min-h-screen items-center justify-center bg-gradient-to-b from-blue-500 to-blue-700">
<div className="text-center text-white">
<h1 className="text-5xl font-bold">AI Chatbot</h1>
<p className="mt-4 text-xl">Built with T3 Turbo + Gemini</p>
<Link
href="/chat"
className="mt-8 inline-block rounded-full bg-white px-10 py-3 font-semibold text-blue-600 hover:bg-gray-100 transition"
>
Start Chatting
</Link>
</div>
</main>
);
}
Uruchom pnpm dev i wejdź na http://localhost:3000. Kliknij „Start Chatting” i masz działającego chatbota AI.
Magia tRPC: Zauważ, że nigdy nie napisaliśmy zapytania do API. Żadnych wywołań fetch(), żadnych stringów z URL-ami, żadnej ręcznej obsługi błędów. TypeScript wie, czego oczekuje sendMsg.mutate(). Jeśli zmienisz schemat wejściowy na backendzie, twój frontend wyrzuci błąd kompilacji. To jest przyszłość.
Krok 6: Wstrzykiwanie Duszy (Test „Vibe'u”)
Generyczny asystent jest nudny. Generyczny asystent zostaje usunięty. Piękno LLM-ów polega na tym, że są doskonałymi aktorami (role-players).
Odkryłem, że nadanie botowi silnej opinii sprawia, że jest 10x bardziej angażujący. Nie promptuj po prostu „Jesteś pomocny”. Promptuj pod kątem osobowości.
Zmodyfikujmy backend, aby akceptował personę. Zaktualizuj packages/api/src/router/chat.ts:
const PROMPTS = {
default: "You are a helpful AI assistant. Be concise and clear.",
luffy:
"You are Monkey D. Luffy from One Piece. You're energetic, optimistic, love meat and adventure. You often say 'I'm gonna be King of the Pirates!' Speak simply and enthusiastically.",
stark:
"You are Tony Stark (Iron Man). You're a genius inventor, witty, and sarcastic. You love technology and often mention Stark Industries. Call people 'kid' or 'buddy'. Be charming but arrogant.",
};
export const chatRouter = {
sendChat: publicProcedure
.input(
z.object({
content: z.string().min(1).max(10000),
character: z.enum(["default", "luffy", "stark"]).optional(),
}),
)
.mutation(async ({ ctx, input }) => {
// Pick the personality
const systemPrompt = PROMPTS[input.character || "default"];
await ctx.db
.insert(Message)
.values({ role: "user", content: input.content });
const history = await ctx.db
.select()
.from(Message)
.orderBy(desc(Message.createdAt))
.limit(10);
const { text } = await generateText({
model: google("gemini-1.5-flash"),
system: systemPrompt, // ← Dynamic prompt
messages: history.reverse().map((m) => ({
role: m.role as "user" | "assistant",
content: m.content,
})),
});
return await ctx.db
.insert(Message)
.values({ role: "assistant", content: text })
.returning();
}),
// ... rest stays the same
};
Zaktualizuj frontend, aby przekazywał wybór postaci:
// In ChatPage component, add state for character
const [character, setCharacter] = useState<"default" | "luffy" | "stark">("default");
// Update the mutation call
sendMsg.mutate({ content: input.trim(), character });
// Add a dropdown before the input:
<select
value={character}
onChange={(e) => setCharacter(e.target.value as any)}
className="rounded-lg border px-3 py-2"
>
<option value="default">🤖 Default</option>
<option value="luffy">👒 Luffy</option>
<option value="stark">🦾 Tony Stark</option>
</select>
Teraz nie zbudowałeś po prostu chatbota; zbudowałeś platformę interakcji z postaciami. To jest produkt.
Szczegóły techniczne, które faktycznie cię obchodzą
Dlaczego nie po prostu Prisma?
Prisma jest świetna, ale Drizzle jest szybszy. Mówimy o 2-3x większej wydajności zapytań. Kiedy jesteś samotnym founderem, każda milisekunda procentuje. Dodatkowo, składnia Drizzle przypominająca SQL oznacza mniejsze obciążenie umysłowe.
Co ze strumieniowaniem odpowiedzi (streaming)?
Vercel AI SDK obsługuje streaming prosto z pudełka. Zamień generateText na streamText i użyj hooka useChat na frontendzie. Pominąłem to tutaj, ponieważ w tutorialu model żądanie/odpowiedź jest prostszy. Ale na produkcji? Strumieniuj wszystko. Użytkownicy postrzegają streaming jako „szybszy”, nawet jeśli całkowity czas jest taki sam.
Zarządzanie oknem kontekstowym
W tej chwili pobieramy ostatnie 10 wiadomości. To działa, dopóki nie przestanie. Jeśli budujesz poważny produkt, zaimplementuj licznik tokenów i dynamicznie dostosowuj historię. AI SDK ma do tego narzędzia.
import { anthropic } from "@ai-sdk/anthropic";
const { text } = await generateText({
model: anthropic("claude-3-5-sonnet-20241022"),
maxTokens: 1000, // Control costs
// ...
});
Pooling połączeń do bazy danych
Lokalny Postgres jest w porządku dla deweloperki. Na produkcję użyj Vercel Postgres lub Supabase. Obsługują pooling połączeń automatycznie. Serverless + połączenia do bazy danych to pułapka – nie zarządzaj tym samemu.
Praktyczne wnioski
Jeśli to czytasz i czujesz swędzenie, żeby zacząć kodować, oto moja rada:
- Nie zaczynaj od zera. Boilerplate to wróg momentum. Użyj T3 Turbo lub podobnego rusztowania.
- Bezpieczeństwo typów to szybkość. Przez pierwszą godzinę wydaje się wolniejsze, a przez następne dziesięć lat szybsze. Wyłapuje błędy, które zazwyczaj zdarzają się podczas demo.
- Kontekst jest kluczowy. Chatbot bez historii to tylko fikuśny pasek wyszukiwania. Zawsze przekazuj kilka ostatnich wiadomości do LLM.
- Osobowość > funkcje. Bot, który brzmi jak Tony Stark, zdobędzie większe zaangażowanie niż generyczny bot z 10 dodatkowymi funkcjami.
Brudna rzeczywistość
Budowanie tego nie było samą sielanką. Początkowo skopałem connection string do bazy danych i spędziłem 20 minut zastanawiając się, dlaczego Drizzle na mnie krzyczy. Uderzyłem też w limit zapytań na Gemini, bo początkowo wysyłałem zbyt dużo historii (lekcja: zawsze zaczynaj od .limit(5) i skaluj w górę).
Animacja ładowania? Zajęło mi to trzy próby, żeby wyszła dobrze, bo animacje CSS w 2024 roku to wciąż, jakimś cudem, czarna magia.
Ale rzecz w tym: ponieważ używałem solidnego stacku, były to problemy logiczne, a nie strukturalne. Fundament trzymał się mocno. Nigdy nie musiałem refaktoryzować całego API, bo wybrałem złą abstrakcję.
Dowieź to (Ship It)
Żyjemy w złotej erze budowania. Narzędzia są potężne, AI jest mądre, a bariera wejścia nigdy nie była niższa.
Masz teraz kod. Masz stack. Rozumiesz kompromisy.
Idź zbudować coś, co nie powinno istnieć, i dowieź to przed kolacją.
Całkowity czas budowy: ~2 godziny Liczba linii napisanego kodu: ~200 Błędy napotkane na produkcji: 0 (jak na razie)
Stack T3 + Gemini nie jest tylko szybki – jest nudny w najlepszym tego słowa znaczeniu. Żadnych niespodzianek. Żadnego „u mnie działa”. Po prostu budowanie.
Miłego kodowania.
Zasoby:
Pełny kod: github.com/giftedunicorn/my-chatbot
Udostępnij to

Feng Liu
shenjian8628@gmail.com