fix: vercel
This commit is contained in:
349
Les13-Agents/lesbestanden/Les13-Stap-voor-stap.md
Normal file
349
Les13-Agents/lesbestanden/Les13-Stap-voor-stap.md
Normal file
@@ -0,0 +1,349 @@
|
||||
# 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`.
|
||||
Reference in New Issue
Block a user