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.

Część 1: Jak zbudować chatbota z T3 Turbo i Gemini
Feng LiuFeng Liu
12 grudnia 2025

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.

Modular Architecture Visualization

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:

  1. Nie zaczynaj od zera. Boilerplate to wróg momentum. Użyj T3 Turbo lub podobnego rusztowania.
  2. 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.
  3. Kontekst jest kluczowy. Chatbot bez historii to tylko fikuśny pasek wyszukiwania. Zawsze przekazuj kilka ostatnich wiadomości do LLM.
  4. 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

Feng Liu

shenjian8628@gmail.com

Część 1: Jak zbudować chatbota z T3 Turbo i Gemini | Feng Liu