350 lines
9.8 KiB
Markdown
350 lines
9.8 KiB
Markdown
# Les 13 — Stap voor stap: van Polderfest naar Polderfest-met-agent
|
|
|
|
Dit document beschrijft welke stappen je doet om van de Polderfest-app
|
|
(Les 11/12) toe te werken naar een echte agent met vijf tools, een eind-
|
|
tool, model-switching en een write-tool.
|
|
|
|
> **Startpunt:** je eigen Polderfest-app uit Les 12, of `polderfest-original.zip`
|
|
> uitpakken.
|
|
> **Eindpunt:** vergelijkbaar met `polderfest-with-agent.zip` — open die als
|
|
> spiekbriefje.
|
|
|
|
## Wat is AL bekend (uit Les 11/12)
|
|
|
|
Dit doen we vandaag **niet opnieuw** — het zit al in je Polderfest-app:
|
|
|
|
- `tool({ description, inputSchema, execute })`
|
|
- `stopWhen: stepCountIs(N)` voor multi-step
|
|
- Supabase-client opzetten
|
|
- `searchBands` en `getDaySchedule` als read-tools
|
|
- Bestaande tabellen `bands` en `user_favorites`
|
|
|
|
## Wat is NIEUW vandaag
|
|
|
|
Zes echt nieuwe features. Allemaal in één nieuwe route + bijbehorende UI.
|
|
|
|
| # | Stap | STEP-anchor (Cmd+F in code) |
|
|
|---|------|------------------------------|
|
|
| 1 | Project klaarzetten | — |
|
|
| 2 | Nieuwe route `app/api/agent/route.ts` | — |
|
|
| 3 | `addToFavorites` write-tool | `STEP 8 — addToFavorites` |
|
|
| 4 | `getWeather` externe API tool | `STEP 7 — getWeather` |
|
|
| 5 | `finishPlan` structured-output tool | `STEP 9 — finishPlan` |
|
|
| 6 | `hasToolCall` extra stop-conditie | `STEP 10 — hasToolCall` |
|
|
| 7 | `prepareStep` voor model-switching | `STEP 11 — prepareStep` |
|
|
| 8 | UI-pagina `/agent` met tool-rendering | `STEP 13 — Tool-call rendering` |
|
|
| 9 | Testen + deployen | — |
|
|
|
|
---
|
|
|
|
## Stap 1 — Project klaarzetten
|
|
|
|
```bash
|
|
unzip polderfest-original.zip
|
|
cd polderfest-original
|
|
cp .env.example .env.local # zelf invullen
|
|
npm install
|
|
npm run dev
|
|
```
|
|
|
|
Browser: `http://localhost:3000` — de chat uit Les 11/12 werkt.
|
|
|
|
Verifieer in Supabase dat de tabel `user_favorites` bestaat met
|
|
kolommen `id`, `user_email`, `band_id`, `created_at`. Die hebben we
|
|
nodig voor de write-tool.
|
|
|
|
---
|
|
|
|
## Stap 2 — Nieuwe route `app/api/agent/route.ts`
|
|
|
|
Kopieer `app/api/chat/route.ts` naar `app/api/agent/route.ts` als
|
|
startpunt. Verwijder daar voor nu eventuele bands-context — we gaan
|
|
de agent puur via tools laten werken.
|
|
|
|
```typescript
|
|
import {
|
|
convertToModelMessages,
|
|
streamText,
|
|
stepCountIs,
|
|
hasToolCall, // ← nieuw vergeleken met Les 12
|
|
tool,
|
|
type UIMessage,
|
|
} from "ai";
|
|
import { openai } from "@ai-sdk/openai";
|
|
import { createClient } from "@supabase/supabase-js";
|
|
import { z } from "zod";
|
|
|
|
const supabase = createClient(
|
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
|
process.env.SUPABASE_SERVICE_ROLE_KEY ??
|
|
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
|
);
|
|
|
|
export async function POST(req: Request) {
|
|
const { messages }: { messages: UIMessage[] } = await req.json();
|
|
const result = streamText({
|
|
model: openai("gpt-5.2"),
|
|
system: "Je bent een Polderfest-agent.",
|
|
messages: await convertToModelMessages(messages),
|
|
stopWhen: stepCountIs(8),
|
|
tools: {
|
|
// searchBands en getDaySchedule overnemen uit chat/route.ts
|
|
},
|
|
});
|
|
return result.toUIMessageStreamResponse();
|
|
}
|
|
```
|
|
|
|
Test eerst dat de route bereikbaar is, bouw daarna stap-voor-stap uit.
|
|
|
|
---
|
|
|
|
## Stap 3 — `addToFavorites` write-tool (NIEUW)
|
|
|
|
Eerste tool die de **wereld verandert** ipv. alleen leest.
|
|
Anchor: `STEP 8 — addToFavorites`
|
|
|
|
```typescript
|
|
addToFavorites: tool({
|
|
description:
|
|
"Voeg een band toe aan de favorieten van de gebruiker. " +
|
|
"Gebruik dit als de gebruiker zegt: ik vind X mooi, voeg toe, etc.",
|
|
inputSchema: z.object({
|
|
bandName: z.string(),
|
|
}),
|
|
execute: async ({ bandName }) => {
|
|
// 1. Zoek band op naam — we hebben band_id nodig (FK)
|
|
const { data: band, error: findErr } = await supabase
|
|
.from("bands")
|
|
.select("id, name")
|
|
.ilike("name", `%${bandName}%`)
|
|
.limit(1)
|
|
.single();
|
|
if (findErr || !band) {
|
|
return { error: `Band '${bandName}' niet gevonden` };
|
|
}
|
|
|
|
// 2. Insert in user_favorites
|
|
const demoUser = "demo@polderwave.app";
|
|
const { data, error } = await supabase
|
|
.from("user_favorites")
|
|
.insert({ user_email: demoUser, band_id: band.id })
|
|
.select()
|
|
.single();
|
|
if (error) return { error: error.message };
|
|
return { added: true, band: band.name, favoriteId: data.id };
|
|
},
|
|
}),
|
|
```
|
|
|
|
Test: zeg tegen de agent *"Voeg Band 5 toe aan mijn favorieten."* en
|
|
check in de Supabase Table Editor of er een rij in `user_favorites`
|
|
verschijnt.
|
|
|
|
---
|
|
|
|
## Stap 4 — `getWeather` externe API (NIEUW)
|
|
|
|
Geen account, geen API-key — Open-Meteo is gratis. Toont dat tools
|
|
ook gewoon `fetch()` kunnen doen naar externe APIs.
|
|
Anchor: `STEP 7 — getWeather`
|
|
|
|
```typescript
|
|
getWeather: tool({
|
|
description: "Weer-forecast voor een festivaldag.",
|
|
inputSchema: z.object({
|
|
date: z.string().describe("ISO datum, bv. '2027-07-10'"),
|
|
}),
|
|
execute: async ({ date }) => {
|
|
const url = new URL("https://api.open-meteo.com/v1/forecast");
|
|
url.searchParams.set("latitude", "52.0907");
|
|
url.searchParams.set("longitude", "5.1214");
|
|
url.searchParams.set(
|
|
"daily",
|
|
"temperature_2m_max,precipitation_probability_max",
|
|
);
|
|
url.searchParams.set("start_date", date);
|
|
url.searchParams.set("end_date", date);
|
|
url.searchParams.set("timezone", "Europe/Amsterdam");
|
|
|
|
const res = await fetch(url);
|
|
if (!res.ok) return { error: `Weather API faalde: ${res.status}` };
|
|
const data = await res.json();
|
|
const d = data?.daily;
|
|
return {
|
|
date,
|
|
temperatureMaxC: d?.temperature_2m_max?.[0],
|
|
rainChancePercent: d?.precipitation_probability_max?.[0],
|
|
};
|
|
},
|
|
}),
|
|
```
|
|
|
|
Festivaldata: vrijdag `2027-07-09`, zaterdag `2027-07-10`, zondag `2027-07-11`.
|
|
|
|
---
|
|
|
|
## Stap 5 — `finishPlan` structured-output tool (NIEUW)
|
|
|
|
Dwingt een gestructureerd eindplan af via Zod-schema. De UI kan dit
|
|
later als nette plan-kaart renderen.
|
|
Anchor: `STEP 9 — finishPlan`
|
|
|
|
```typescript
|
|
finishPlan: tool({
|
|
description:
|
|
"Roep dit ALS LAATSTE aan. Geef het eindprogramma in gestructureerde vorm. " +
|
|
"De agent stopt automatisch na deze aanroep.",
|
|
inputSchema: z.object({
|
|
summary: z.string().describe("Korte samenvatting"),
|
|
items: z
|
|
.array(
|
|
z.object({
|
|
time: z.string().describe("HH:MM"),
|
|
band: z.string(),
|
|
stage: z.string(),
|
|
reason: z.string(),
|
|
}),
|
|
)
|
|
.describe("Items in chronologische volgorde"),
|
|
}),
|
|
execute: async ({ summary, items }) => {
|
|
return { summary, items, ok: true };
|
|
},
|
|
}),
|
|
```
|
|
|
|
---
|
|
|
|
## Stap 6 — `hasToolCall` extra stop-conditie (NIEUW)
|
|
|
|
Combineer `stepCountIs(8)` met `hasToolCall("finishPlan")`:
|
|
Anchor: `STEP 10 — hasToolCall`
|
|
|
|
```typescript
|
|
import { stepCountIs, hasToolCall } from "ai";
|
|
|
|
streamText({
|
|
// ...
|
|
stopWhen: [
|
|
stepCountIs(8), // max 8 stappen (veiligheid)
|
|
hasToolCall("finishPlan"), // klaar-signaal van de agent
|
|
],
|
|
});
|
|
```
|
|
|
|
Effect: zodra de agent `finishPlan` aanroept stopt de loop direct.
|
|
Gegarandeerde nette afsluiting, ongeacht resterende stappen.
|
|
|
|
---
|
|
|
|
## Stap 7 — `prepareStep` voor model-switching (NIEUW)
|
|
|
|
Per stap een ander model. Stap 0 (planning) krijgt het krachtige model.
|
|
Stap 1+ (uitvoering van tool-calls) gebruikt een lichter model.
|
|
Anchor: `STEP 11 — prepareStep`
|
|
|
|
```typescript
|
|
streamText({
|
|
model: openai("gpt-5.2"), // default
|
|
|
|
prepareStep: ({ stepNumber }) => {
|
|
if (stepNumber === 0) {
|
|
return { model: openai("gpt-5.2") }; // planning
|
|
}
|
|
return { model: openai("gpt-5-mini") }; // uitvoering
|
|
},
|
|
|
|
// ...rest van je streamText config
|
|
});
|
|
```
|
|
|
|
Resultaat: significant lagere kosten en latency zonder kwaliteitsverlies
|
|
op het denkwerk.
|
|
|
|
---
|
|
|
|
## Stap 8 — UI-pagina `/agent` met tool-rendering (NIEUW)
|
|
|
|
Maak `app/agent/page.tsx`. Verschil met `/`:
|
|
Anchor: `STEP 13 — Tool-call rendering`
|
|
|
|
```typescript
|
|
const { messages, sendMessage, status } = useChat({
|
|
api: "/api/agent", // ← belangrijk: agent-route
|
|
});
|
|
```
|
|
|
|
Speciaal renderen voor `finishPlan` en `addToFavorites`:
|
|
|
|
```tsx
|
|
{m.parts.map((p, i) => {
|
|
if (p.type === "text") return <div>{p.text}</div>;
|
|
|
|
if (p.type === "tool-finishPlan") {
|
|
return <PlanCard result={p.result} />; // nette timeline
|
|
}
|
|
if (p.type === "tool-addToFavorites") {
|
|
return <FavoriteBadge name={p.input.bandName} />; // ⭐ badge
|
|
}
|
|
if (p.type.startsWith("tool-")) {
|
|
return <ToolDetails part={p} />; // expandable JSON
|
|
}
|
|
})}
|
|
```
|
|
|
|
De gestructureerde output uit `finishPlan` kun je nu prachtig
|
|
renderen — geen vrije-vorm-tekst meer die je moet parsen.
|
|
|
|
---
|
|
|
|
## Stap 9 — Testen in de browser
|
|
|
|
Open `localhost:3000/agent`. Probeer:
|
|
|
|
> "Plan een avond met indie en techno voor zaterdag."
|
|
|
|
→ meerdere tool-calls + `finishPlan` aan het eind. Plan-kaart in de UI.
|
|
|
|
> "Welke band moet ik NIET missen op vrijdag? Zet hem in mijn favorieten."
|
|
|
|
→ Tool `addToFavorites` aangeroepen, badge in UI, rij in `user_favorites`.
|
|
|
|
> "Wat speelt er op zondag en hoe is het weer?"
|
|
|
|
→ Externe weer-API call. Agent combineert weer + bands.
|
|
|
|
---
|
|
|
|
## Stap 10 — Deploy
|
|
|
|
Push naar GitHub, importeer in Vercel, env vars:
|
|
- `NEXT_PUBLIC_SUPABASE_URL`
|
|
- `NEXT_PUBLIC_SUPABASE_ANON_KEY`
|
|
- `SUPABASE_SERVICE_ROLE_KEY`
|
|
- `OPENAI_API_KEY`
|
|
|
|
---
|
|
|
|
## Samenvatting — wat je nu hebt
|
|
|
|
Zes nieuwe agent-features bovenop Les 12:
|
|
|
|
| Feature | Voorbeeld in code |
|
|
|---------|------------------|
|
|
| `addToFavorites` — write naar `user_favorites` | STEP 8 |
|
|
| `getWeather` — externe Open-Meteo API | STEP 7 |
|
|
| `finishPlan` — Zod-schema structured output | STEP 9 |
|
|
| `hasToolCall` extra stop-conditie | STEP 10 |
|
|
| `prepareStep` model per stap | STEP 11 |
|
|
| UI met tool-call rendering | STEP 13 |
|
|
|
|
Plus de bestaande tools (`searchBands`, `getDaySchedule`) en patterns
|
|
(`tool()`, `stopWhen`, Supabase) die je al uit Les 11/12 kende.
|
|
|
|
## Vastgelopen?
|
|
|
|
`polderfest-with-agent.zip` is de complete referentie.
|
|
Zoek op `// STEP N` om direct bij de juiste plek te komen.
|
|
Reset `user_favorites` tussen demos met `supabase-reset-user-favorites.sql`.
|