528 lines
15 KiB
Markdown
528 lines
15 KiB
Markdown
# Les 11 — Vercel AI SDK
|
||
## Lesstof
|
||
|
||
**Vak:** AI-Assisted Development
|
||
**Opleiding:** NOVI Hogeschool Utrecht
|
||
**Onderwerp:** Vercel AI SDK — AI features bouwen, gekoppeld aan eigen dataset
|
||
**Demo:** Polderfest 2027 — fictief festival, 500 records
|
||
|
||
---
|
||
|
||
## Inhoudsopgave
|
||
|
||
1. [Wat is de Vercel AI SDK?](#1-wat-is-de-vercel-ai-sdk)
|
||
2. [Het modellen-landschap](#2-het-modellen-landschap)
|
||
3. [De vier kern-functies](#3-de-vier-kern-functies)
|
||
4. [Project setup van A tot Z](#4-project-setup-van-a-tot-z)
|
||
5. [Seed script: dummy data in Supabase](#5-seed-script-dummy-data-in-supabase)
|
||
6. [Chat-route + chat-UI implementeren](#6-chat-route--chat-ui-implementeren)
|
||
7. [Waarom data + AI samen krachtig zijn](#7-waarom-data--ai-samen-krachtig-zijn)
|
||
8. [Best practices & valkuilen](#8-best-practices--valkuilen)
|
||
9. [Wat komt hierna? Tool Calling teaser](#9-wat-komt-hierna)
|
||
10. [Bronnen](#10-bronnen)
|
||
|
||
---
|
||
|
||
## 1. Wat is de Vercel AI SDK?
|
||
|
||
De Vercel AI SDK is een open-source TypeScript library waarmee je AI-features in je webapplicatie bouwt. Gemaakt door Vercel — de makers van Next.js — en daarom perfect geïntegreerd met React, Server Components en Server Actions.
|
||
|
||
### Wat krijg je?
|
||
|
||
- **Unified API** — Zelfde code voor elk model (40+ providers)
|
||
- **Streaming out-of-the-box** — Geen WebSocket-setup nodig
|
||
- **React hooks** — `useChat`, `useCompletion` voor instant chat UI
|
||
- **Tool Calling** — AI kan functies aanroepen die jij definieert (volgende les)
|
||
- **Structured output** — Type-safe data via `generateObject` + Zod
|
||
- **Multi-step agents** — Via `maxSteps`
|
||
|
||
### Eerste indruk
|
||
|
||
```typescript
|
||
import { generateText } from "ai";
|
||
import { openai } from "@ai-sdk/openai";
|
||
|
||
const { text } = await generateText({
|
||
model: openai("gpt-4o-mini"),
|
||
prompt: "Vat de Polderfest 2027 line-up samen",
|
||
});
|
||
```
|
||
|
||
Wil je naar Anthropic? Eén regel veranderen:
|
||
|
||
```typescript
|
||
import { anthropic } from "@ai-sdk/anthropic";
|
||
|
||
const { text } = await generateText({
|
||
model: anthropic("claude-sonnet-4"),
|
||
prompt: "Vat de Polderfest 2027 line-up samen",
|
||
});
|
||
```
|
||
|
||
Dat is de waarde.
|
||
|
||
---
|
||
|
||
## 2. Het modellen-landschap
|
||
|
||
| Provider | Model | Sterke punten | Prijs (in/out per 1M tokens) |
|
||
|----------|-------|---------------|------------------------------|
|
||
| OpenAI | `gpt-4o-mini` | Snel, goedkoop, default | $0.15 / $0.60 |
|
||
| OpenAI | `gpt-4o` | Multimodal (vision), krachtig | $2.50 / $10 |
|
||
| OpenAI | `gpt-4.1` | Reasoning, voor agents | $2 / $8 |
|
||
| Anthropic | `claude-sonnet-4` | Coding, lange context (200k) | $3 / $15 |
|
||
| Google | `gemini-2.5-flash` | Multimodal, ultra goedkoop | $0.075 / $0.30 |
|
||
| Groq | `llama-3.3-70b` | Ultra-fast inference | $0.59 / $0.79 |
|
||
|
||
**Vuistregel:** start met `gpt-4o-mini`. Werkt het niet goed? Upgrade naar `gpt-4o`. Dan pas exotisch.
|
||
|
||
**Wat kost onze Polderfest demo?**
|
||
- Context = 500 bands → ~30.000 tokens per chat-request
|
||
- 1 vraag = ~$0.005 (een halve cent)
|
||
- 50 vragen = $0.25 (kwart euro)
|
||
|
||
Met gpt-4o (15× duurder) zou dezelfde demo ~$4 kosten. Daarom: start mini.
|
||
|
||
---
|
||
|
||
## 3. De vier kern-functies
|
||
|
||
### `generateText` — Antwoord ophalen
|
||
Wacht tot het AI-antwoord compleet is, returnt dan een string.
|
||
```typescript
|
||
const { text } = await generateText({
|
||
model: openai("gpt-4o-mini"),
|
||
prompt: "Geef 3 koffie-poll opties",
|
||
});
|
||
```
|
||
**Wanneer:** Korte antwoorden, server-only, niet-interactief.
|
||
|
||
### `streamText` — Streaming antwoord
|
||
Streamt karakter voor karakter. Goed voor chat UI.
|
||
```typescript
|
||
const result = streamText({
|
||
model: openai("gpt-4o-mini"),
|
||
messages,
|
||
});
|
||
return result.toDataStreamResponse();
|
||
```
|
||
**Wanneer:** Chat UI, lange antwoorden, "ChatGPT-gevoel". Vandaag gebruiken we dit.
|
||
|
||
### `useChat` — React hook
|
||
Aan client-kant: complete chat UI in 10 regels.
|
||
```tsx
|
||
"use client";
|
||
import { useChat } from "ai/react";
|
||
|
||
export default function Chat() {
|
||
const { messages, input, handleInputChange, handleSubmit } = useChat();
|
||
return (
|
||
<form onSubmit={handleSubmit}>
|
||
{messages.map(m => <div key={m.id}>{m.role}: {m.content}</div>)}
|
||
<input value={input} onChange={handleInputChange} />
|
||
</form>
|
||
);
|
||
}
|
||
```
|
||
**Belangrijk:** werkt alleen met `streamText` als API endpoint. Vandaag gebruiken we dit ook.
|
||
|
||
### `generateObject` — Gestructureerde data
|
||
In plaats van een string krijg je type-safe data terug — gevalideerd met Zod.
|
||
```typescript
|
||
const { object } = await generateObject({
|
||
model: openai("gpt-4o-mini"),
|
||
schema: z.object({
|
||
question: z.string(),
|
||
options: z.array(z.string()).length(4),
|
||
}),
|
||
prompt: "Maak een poll over koffie",
|
||
});
|
||
```
|
||
**Wanneer:** Database inserts, formulieren vullen, classificatie. **Niet** vandaag — komt terug in latere lessen.
|
||
|
||
---
|
||
|
||
## 4. Project setup van A tot Z
|
||
|
||
Dit is wat Tim in de les live deed. Voor je eigen project (lesopdracht/huiswerk): zelfde stappen.
|
||
|
||
### Stap 1 — Next.js scaffolden
|
||
|
||
```bash
|
||
npx create-next-app@latest mijn-thema \
|
||
--typescript --tailwind --app --eslint --no-src-dir --turbopack
|
||
cd mijn-thema
|
||
```
|
||
|
||
### Stap 2 — Supabase project aanmaken
|
||
|
||
- Ga naar https://supabase.com/dashboard
|
||
- **New Project** → kies naam
|
||
- Wacht ~2 min op deploy
|
||
- Settings → API → kopieer:
|
||
- Project URL
|
||
- anon public key
|
||
- service_role secret key
|
||
|
||
### Stap 3 — Schema definiëren
|
||
|
||
Pas `schema.sql` aan voor jouw thema. Bv:
|
||
```sql
|
||
create table items (
|
||
id bigserial primary key,
|
||
name text not null,
|
||
category text,
|
||
rating int,
|
||
description text,
|
||
created_at timestamp default now()
|
||
);
|
||
```
|
||
|
||
Run in Supabase → SQL Editor.
|
||
|
||
### Stap 4 — Env variables
|
||
|
||
`.env.local`:
|
||
```
|
||
NEXT_PUBLIC_SUPABASE_URL=https://xxx.supabase.co
|
||
NEXT_PUBLIC_SUPABASE_ANON_KEY=...
|
||
SUPABASE_SERVICE_ROLE_KEY=...
|
||
OPENAI_API_KEY=sk-proj-...
|
||
```
|
||
|
||
Belangrijke regels:
|
||
- `NEXT_PUBLIC_*` = client-leesbaar
|
||
- `SUPABASE_SERVICE_ROLE_KEY` = server-only, voor seed script (geen `NEXT_PUBLIC_`)
|
||
- `OPENAI_API_KEY` = server-only (geen `NEXT_PUBLIC_`)
|
||
|
||
### Stap 5 — Packages installeren
|
||
|
||
```bash
|
||
npm install @supabase/supabase-js ai @ai-sdk/openai zod
|
||
npm install --save-dev tsx dotenv
|
||
```
|
||
|
||
### Stap 6 — Supabase client
|
||
|
||
`lib/supabase.ts`:
|
||
```typescript
|
||
import { createClient } from "@supabase/supabase-js";
|
||
|
||
export const supabase = createClient(
|
||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||
);
|
||
```
|
||
|
||
---
|
||
|
||
## 5. Seed script: dummy data in Supabase
|
||
|
||
Een seed-script is een TypeScript bestand dat **éénmalig** je tabel vult met dummy data. Geen handmatige inserts — procedureel gegenereerd.
|
||
|
||
### Het Polderfest script (voorbeeld)
|
||
|
||
Het volledige `seed-polderfest.ts` zit als bijlage bij deze les. Kernidee:
|
||
|
||
```typescript
|
||
import { createClient } from "@supabase/supabase-js";
|
||
import "dotenv/config";
|
||
|
||
const supabase = createClient(
|
||
process.env.SUPABASE_URL!,
|
||
process.env.SUPABASE_SERVICE_ROLE_KEY!,
|
||
);
|
||
|
||
const adjectives = ["Lost", "Velvet", "Iron", "Neon", "Silent", ...];
|
||
const nouns = ["Tigers", "Wolves", "Mirrors", "Clouds", ...];
|
||
const genres = ["Indie Rock", "Electronic", ...];
|
||
|
||
function generateBand(i: number) {
|
||
return {
|
||
name: `${pick(adjectives)} ${pick(nouns)}`,
|
||
genre: pick(genres),
|
||
// ...
|
||
};
|
||
}
|
||
|
||
async function seed() {
|
||
const bands = [];
|
||
for (let i = 0; i < 500; i++) bands.push(generateBand(i));
|
||
|
||
for (let i = 0; i < bands.length; i += 100) {
|
||
await supabase.from("bands").insert(bands.slice(i, i + 100));
|
||
}
|
||
}
|
||
|
||
seed();
|
||
```
|
||
|
||
### Runnen
|
||
|
||
```bash
|
||
npx tsx scripts/seed-polderfest.ts
|
||
```
|
||
|
||
### Waarom procedureel?
|
||
|
||
- **Met 500 hard-coded records** = 500 regels handmatige data → mind-numbing
|
||
- **Met combinaties** van 30 adjectives × 30 nouns = 900 unieke namen mogelijk
|
||
- **Met seed-random** = reproduceerbaar (zelfde data bij re-run)
|
||
|
||
### Voor jouw eigen thema
|
||
|
||
Open `seed-polderfest.ts`, kopieer de structuur, en vervang de **bouwstenen**:
|
||
- Domein-specifieke arrays (in plaats van bands: restaurants, scripties, kunstwerken…)
|
||
- Domein-specifieke velden
|
||
- Domein-specifieke bio/beschrijving-fragmenten
|
||
|
||
**Pro tip:** vraag een AI om dit te doen! "Pas het Polderfest seed-script aan voor [thema]." OpenCode of Cursor doet dit in 30 seconden.
|
||
|
||
---
|
||
|
||
## 6. Chat-route + chat-UI implementeren
|
||
|
||
Dit is wat **alleen nieuw** is — Next.js + Supabase kennen jullie al.
|
||
|
||
### De chat-route
|
||
|
||
`app/api/chat/route.ts`:
|
||
|
||
```typescript
|
||
import { streamText } from "ai";
|
||
import { openai } from "@ai-sdk/openai";
|
||
import { createClient } from "@supabase/supabase-js";
|
||
|
||
const supabase = createClient(
|
||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||
);
|
||
|
||
export async function POST(req: Request) {
|
||
const { messages } = await req.json();
|
||
|
||
// 1. Haal data op
|
||
const { data: bands } = await supabase.from("bands").select("*");
|
||
|
||
// 2. Format als context
|
||
const context = bands!
|
||
.map((b) =>
|
||
`- ${b.name} (${b.genre}, ${b.tier}, ${b.day} ${b.start_time})`,
|
||
)
|
||
.join("\n");
|
||
|
||
// 3. System prompt met context
|
||
const system = `Je bent een festival-assistent voor Polderfest 2027.
|
||
Hier zijn alle bands:
|
||
|
||
${context}
|
||
|
||
Beantwoord vragen op basis van bovenstaande data. Verzin niets.
|
||
Antwoord in het Nederlands.`;
|
||
|
||
// 4. Stream
|
||
const result = streamText({
|
||
model: openai("gpt-4o-mini"),
|
||
system,
|
||
messages,
|
||
});
|
||
|
||
return result.toDataStreamResponse();
|
||
}
|
||
```
|
||
|
||
### De chat-pagina
|
||
|
||
`app/chat/page.tsx`:
|
||
|
||
```tsx
|
||
"use client";
|
||
import { useChat } from "ai/react";
|
||
|
||
export default function ChatPage() {
|
||
const { messages, input, handleInputChange, handleSubmit, status } =
|
||
useChat();
|
||
|
||
return (
|
||
<main className="max-w-2xl mx-auto p-6 flex flex-col h-screen">
|
||
<h1 className="text-2xl font-bold mb-4">Polderfest 2027 — vraag de AI</h1>
|
||
|
||
<div className="flex-1 overflow-y-auto space-y-4 mb-4">
|
||
{messages.map((m) => (
|
||
<div
|
||
key={m.id}
|
||
className={m.role === "user" ? "bg-blue-50 p-3 rounded-lg ml-12"
|
||
: "bg-gray-50 p-3 rounded-lg mr-12"}
|
||
>
|
||
<div className="font-medium text-sm text-gray-500 mb-1">
|
||
{m.role === "user" ? "Jij" : "Festival AI"}
|
||
</div>
|
||
<div className="whitespace-pre-wrap">{m.content}</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<form onSubmit={handleSubmit} className="flex gap-2">
|
||
<input
|
||
value={input}
|
||
onChange={handleInputChange}
|
||
placeholder="Stel een vraag over de line-up..."
|
||
className="flex-1 p-3 border rounded-lg"
|
||
/>
|
||
<button
|
||
type="submit"
|
||
className="px-6 py-3 bg-blue-600 text-white rounded-lg"
|
||
>
|
||
Stuur
|
||
</button>
|
||
</form>
|
||
</main>
|
||
);
|
||
}
|
||
```
|
||
|
||
### Voorbeeld-vragen die we live stelden
|
||
|
||
| Vraag | Wat AI doet |
|
||
|-------|-------------|
|
||
| "Welke bands spelen zaterdag op de Beach Stage?" | Filtert door context |
|
||
| "3 headliners met meeste populariteit" | Sorteert + select top |
|
||
| "Hoeveel jazz fusion acts totaal?" | Telt |
|
||
| "Vat de electronic-scene samen" | **Samenvatting** — alleen AI kan dit |
|
||
| "Wie was hoofdact van Polderfest 2025?" | Eerlijk: "weet ik niet" — perfect |
|
||
|
||
---
|
||
|
||
## 7. Waarom data + AI samen krachtig zijn
|
||
|
||
### Data alleen
|
||
- SQL queries: filter, sort, select
|
||
- Geen interpretatie, geen taal, geen samenvatting
|
||
- Gebruiker moet zelf SQL kunnen
|
||
|
||
### AI alleen
|
||
- Kennis is generiek (training data)
|
||
- Verzint vaak (hallucinatie)
|
||
- Geen toegang tot live data of private data
|
||
|
||
### Data + AI
|
||
- AI filtert via reasoning op tekst-context
|
||
- Antwoorden in natuurlijke taal
|
||
- Samenvattingen en interpretatie
|
||
- Domein-kennis = jouw data, AI redeneert erover
|
||
|
||
> "Een LLM zonder jouw data is een gewone chatbot.
|
||
> Een LLM mét jouw data is een product."
|
||
|
||
---
|
||
|
||
## 8. Best practices & valkuilen
|
||
|
||
### Doen
|
||
|
||
- **Begin met `gpt-4o-mini`** — upgrade pas als nodig
|
||
- **System prompt is essentieel** — "gebruik alleen onze data, verzin niets"
|
||
- **Stream alles** — `streamText` voelt 5× sneller dan `generateText`
|
||
- **AI-calls altijd server-side** — keys blijven veilig
|
||
- **Loading state** — AI duurt 1-5 sec, zonder feedback voelt het stuk
|
||
- **Foutafhandeling** — `try/catch` rond elke AI-call
|
||
|
||
### Niet doen
|
||
|
||
- **Geen `NEXT_PUBLIC_OPENAI_API_KEY`** — wordt zichtbaar in client
|
||
- **Niet de output blind vertrouwen** — AI hallucineert
|
||
- **Niet alle data altijd meesturen** — werkt voor 500 records, niet voor 50.000 (volgende les)
|
||
- **Niet `gpt-4o` als default** — 15× duurder dan mini, vaak onnodig
|
||
|
||
### Veelvoorkomende fouten
|
||
|
||
| Fout | Oorzaak | Oplossing |
|
||
|------|---------|-----------|
|
||
| `OPENAI_API_KEY is not defined` | `.env.local` niet geladen | Dev server herstarten |
|
||
| `Cannot find module 'ai'` | npm install vergeten | `npm i ai @ai-sdk/openai` |
|
||
| Seed: `permission denied` | Anon key i.p.v. service role | Gebruik `SUPABASE_SERVICE_ROLE_KEY` |
|
||
| AI antwoordt in Engels | Niet expliciet om NL gevraagd | System prompt aanpassen |
|
||
| AI verzint feiten | System prompt te zwak | Voeg toe: "verzin niets, gebruik alleen onze data" |
|
||
| Chat laadt niet | `useChat` zonder `streamText` API | Endpoint moet `result.toDataStreamResponse()` returnen |
|
||
|
||
---
|
||
|
||
## 9. Wat komt hierna?
|
||
|
||
### Het schaalprobleem
|
||
|
||
Vandaag sturen we **alle 500 bands** mee als context bij elke request. Dat is ~30k tokens. Werkt prima voor 500. Werkt **niet** voor:
|
||
- 5.000 records → te duur, te traag
|
||
- 50.000 records → past niet in context window
|
||
- Real-time data → context wordt steeds opnieuw gebouwd
|
||
|
||
### Volgende les: Tool Calling (Les 12)
|
||
|
||
In plaats van **alle data te sturen**, geef je de AI **tools** (functies). De AI besluit zelf welke te gebruiken:
|
||
|
||
```typescript
|
||
const { text } = await generateText({
|
||
model: openai("gpt-4o-mini"),
|
||
messages,
|
||
tools: {
|
||
searchBands: tool({
|
||
description: "Zoek bands op dag, stage, of genre",
|
||
parameters: z.object({
|
||
day: z.string().optional(),
|
||
stage: z.string().optional(),
|
||
genre: z.string().optional(),
|
||
}),
|
||
execute: async ({ day, stage, genre }) => {
|
||
// Supabase query
|
||
const { data } = await supabase.from("bands").select("*")
|
||
.eq("day", day || undefined)
|
||
.eq("stage", stage || undefined);
|
||
return data;
|
||
},
|
||
}),
|
||
},
|
||
maxSteps: 5,
|
||
});
|
||
```
|
||
|
||
Workflow:
|
||
1. User: "Welke bands op vrijdag?"
|
||
2. AI: "Ik roep `searchBands({ day: 'Vrijdag' })` aan"
|
||
3. Supabase: 60 bands terug
|
||
4. AI: "Op vrijdag spelen 60 bands. De headliners zijn..."
|
||
|
||
Schaalbaar. Slim. Multi-step (combineer meerdere tools).
|
||
|
||
### Daarna in deze leerlijn
|
||
|
||
- **Les 13:** Agents + `maxSteps` (autonome multi-step taken)
|
||
- **Les 14:** RAG + embeddings (semantic search op heel grote datasets)
|
||
- **Les 15-16:** Testing + Deployment + Performance
|
||
- **Les 17-18:** Eindopdracht-werkdagen + Pitch
|
||
|
||
---
|
||
|
||
## 10. Bronnen
|
||
|
||
### Vercel AI SDK
|
||
- Hoofdpagina: https://ai-sdk.dev/docs/introduction
|
||
- Voorbeelden: https://ai-sdk.dev/examples
|
||
- `streamText`: https://ai-sdk.dev/docs/reference/ai-sdk-core/stream-text
|
||
- `useChat`: https://ai-sdk.dev/docs/reference/ai-sdk-ui/use-chat
|
||
- Tool Calling (volgende les): https://ai-sdk.dev/docs/foundations/tools
|
||
|
||
### Supabase
|
||
- JS Client: https://supabase.com/docs/reference/javascript
|
||
- Row Level Security: https://supabase.com/docs/guides/auth/row-level-security
|
||
- Server-side usage: https://supabase.com/docs/guides/auth/server-side
|
||
|
||
### Inspiratie
|
||
- v0.dev — Generative UI in actie
|
||
- chat.vercel.ai — Officiële demo van AI SDK
|
||
- Vercel templates met AI: https://vercel.com/templates?type=ai
|
||
|
||
### Tokens & kosten
|
||
- OpenAI pricing: https://openai.com/api/pricing
|
||
- Tokenizer: https://platform.openai.com/tokenizer
|
||
- Usage dashboard: https://platform.openai.com/usage
|