This commit is contained in:
2026-06-09 06:33:34 +02:00
parent 4f13aadc1a
commit dc284db9b6
172 changed files with 437 additions and 15151 deletions

Binary file not shown.

Binary file not shown.

View File

@@ -1,41 +0,0 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

View File

@@ -1,5 +0,0 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
<!-- END:nextjs-agent-rules -->

View File

@@ -1 +0,0 @@
@AGENTS.md

View File

@@ -1,57 +0,0 @@
# Polderfest demo — Les 12 startpunt
Kopie van de werkende Les 11 demo. **Startpunt** voor Les 12 — Tool Calling.
## Wat zit hier al in?
- Next.js 16 + TypeScript + Tailwind
- Supabase client (`lib/supabase.ts` voor client, `lib/supabase-admin.ts` voor service-role)
- Chat-route met **context-all** aanpak (`app/api/chat/route.ts`):
- Haalt alle 500 bands op
- Stuurt mee als tekst in system prompt
- `streamText``toUIMessageStreamResponse()`
- Chat-pagina met `useChat` van `@ai-sdk/react` (`app/page.tsx`)
- Seed script (`scripts/seed-polderfest.ts`)
- `AGENTS.md` met project context
## Wat doen we in Les 12?
We refactoren `app/api/chat/route.ts` van **context-all** naar **Tool Calling**:
- Weg met de hele context-string
- Tools definiëren — `searchBands`, `getStats`, `getBandByName`, ...
- `stopWhen: stepCountIs(5)` voor multi-step
- System prompt aanpassen: "gebruik tools, verzin niet"
- UI uitbreiden om tool-invocations te tonen
## Setup
```bash
# 1. Dependencies installeren
npm install
# 2. .env.local maken
cp .env.local.example .env.local
# Vul je eigen Supabase + OpenAI keys in
# 3. Supabase schema (als nog niet gedaan)
# Open Supabase → SQL Editor → run schema.sql uit Les 11
# 4. Seed (als nog niet gedaan)
npx tsx scripts/seed-polderfest.ts
# 5. Dev server
npm run dev
```
Open `http://localhost:3000` voor de chat.
## Stack-versies in dit project
- `ai` v6 — gebruikt nieuwere API:
- `UIMessage` type
- `convertToModelMessages()`
- `toUIMessageStreamResponse()`
- `@ai-sdk/react` v3 — `useChat` returnt `{ messages, sendMessage, status }`
- `@ai-sdk/openai` v3
- Tool API (v6): gebruikt `inputSchema` (was `parameters`) en `stopWhen` (was `maxSteps`)

View File

@@ -1,97 +0,0 @@
/**
* Polderfest 2027 — chat API route
* --------------------------------------------------
* Les 11 — Vercel AI SDK + Supabase context.
* Plaats dit bestand op: app/api/chat/route.ts
*
* Werking:
* 1. Haal alle bands op uit Supabase
* 2. Formatteer als tekst-context
* 3. Stuur naar OpenAI via streamText + system prompt
* 4. Return een stream voor useChat
*
* Vereist:
* - NEXT_PUBLIC_SUPABASE_URL en NEXT_PUBLIC_SUPABASE_ANON_KEY in .env.local
* - OPENAI_API_KEY in .env.local
* - npm i ai @ai-sdk/openai @supabase/supabase-js
*/
import { convertToModelMessages, streamText, type UIMessage } from "ai";
import { openai } from "@ai-sdk/openai";
import { createClient } from "@supabase/supabase-js";
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseKey =
process.env.SUPABASE_SERVICE_ROLE_KEY ??
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
if (!supabaseUrl) {
throw new Error("Missing env: NEXT_PUBLIC_SUPABASE_URL");
}
if (!supabaseKey) {
throw new Error(
"Missing env: SUPABASE_SERVICE_ROLE_KEY (preferred) or NEXT_PUBLIC_SUPABASE_ANON_KEY",
);
}
const supabase = createClient(supabaseUrl, supabaseKey);
export async function POST(req: Request) {
try {
const { messages }: { messages: UIMessage[] } = await req.json();
// 1. Haal alle bands op uit Supabase
const { data: bands, error } = await supabase.from("bands").select("*");
if (error) {
return Response.json(
{ error: "Supabase query failed", details: error.message },
{ status: 500 },
);
}
// 2. Format bands als context-string
const context = (bands ?? [])
.map(
(b) =>
`- ${b.name} (${b.genre}, ${b.tier}, ${b.day} ${b.start_time} ` +
`op ${b.stage}, uit ${b.origin_city}), populariteit: ${b.popularity}`,
)
.join("\n");
// 3. System prompt met context
const system = `Je bent een festival-assistent voor Polderfest 2027.
Hier zijn alle bands die op het festival spelen:
${context}
Beantwoord vragen van bezoekers over de line-up. Verzin niets — gebruik
alleen bovenstaande data. Antwoord in het Nederlands. Wees beknopt. Geef puur antwoord op de gestelde vraag en voeg geen extra informatie toe.
Zodra iemand iets vraagt over het schema. Geef dan altijd een volledig schema terug met band, stage en start en eind tijd. Sorteer op start tijd.
Wanneer een band speelt op bijvoorbeeld vrijdagavond, geef hem dan niet weer als iemand vraagt om de bands van zaterdag.
Regels:
- Zaterdag start om 16:00
`;
// 4. Stream naar OpenAI
const result = streamText({
model: openai('gpt-5.2'),
system,
messages: await convertToModelMessages(messages),
});
return result.toUIMessageStreamResponse({
onError(error: unknown) {
if (error == null) return "unknown error";
if (typeof error === "string") return error;
if (error instanceof Error) return error.message;
return JSON.stringify(error);
},
});
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
return Response.json({ error: "Chat route failed", details: message }, { status: 500 });
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -1,26 +0,0 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}

View File

@@ -1,33 +0,0 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html
lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col">{children}</body>
</html>
);
}

View File

@@ -1,89 +0,0 @@
/**
* Polderfest 2027 — chat pagina
* --------------------------------------------------
* Les 11 — useChat hook + Tailwind chat UI.
* Plaats dit bestand op: app/chat/page.tsx
*
* Werking:
* - useChat() regelt messages, input, submit-handler, streaming
* - Praat met /api/chat (de route.ts)
* - Disabled tijdens streaming
*
* Vereist:
* - app/api/chat/route.ts (zie route.ts)
* - npm i ai
* - Tailwind aanwezig in project (standaard in create-next-app)
*/
"use client";
import { useState } from "react";
import { useChat } from "@ai-sdk/react";
export default function ChatPage() {
const { messages, sendMessage, status } = useChat();
const [input, setInput] = useState("");
return (
<main className="max-w-2xl mx-auto p-6 flex flex-col h-screen">
<div className="flex items-center justify-between gap-3 mb-4">
<h1 className="text-2xl font-bold">Polderfest 2027 vraag de AI</h1>
<a
href="/supabase"
className="text-sm px-3 py-2 rounded-lg border hover:bg-gray-50"
>
Supabase test
</a>
</div>
<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.parts
.filter((p) => p.type === "text")
.map((p) => p.text)
.join("")}
</div>
</div>
))}
</div>
<form
onSubmit={(e) => {
e.preventDefault();
const text = input.trim();
if (!text || status !== "ready") return;
setInput("");
void sendMessage({ text });
}}
className="flex gap-2"
>
<input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Stel een vraag over de line-up..."
className="flex-1 p-3 border rounded-lg"
disabled={status !== "ready"}
/>
<button
type="submit"
disabled={status !== "ready"}
className="px-6 py-3 bg-blue-600 text-white rounded-lg disabled:opacity-50"
>
Stuur
</button>
</form>
</main>
);
}

View File

@@ -1,61 +0,0 @@
import { supabaseAdmin } from "@/lib/supabase-admin";
type Band = {
id: number;
name: string;
genre: string;
stage: string;
day: string;
start_time: string;
};
export default async function SupabasePage() {
const { data, error } = await supabaseAdmin
.from("bands")
.select("id,name,genre,stage,day,start_time")
.order("popularity", { ascending: false })
.limit(10);
return (
<main className="max-w-3xl mx-auto p-6 space-y-6">
<header className="space-y-1">
<h1 className="text-2xl font-bold">Supabase verbinding</h1>
<p className="text-sm text-gray-600">
Dit is een Server Component die met de service role key de top 10 bands
uit de <code className="px-1 py-0.5 bg-gray-100 rounded">bands</code>{" "}
tabel ophaalt.
</p>
</header>
{error ? (
<div className="p-4 border border-red-200 bg-red-50 rounded-lg">
<div className="font-medium text-red-800">
Query faalde: {error.message}
</div>
<div className="text-sm text-red-700 mt-2">
Check of de <code className="px-1 py-0.5 bg-white/70 rounded">bands</code>{" "}
tabel bestaat en of je <code className="px-1 py-0.5 bg-white/70 rounded">SUPABASE_SERVICE_ROLE_KEY</code>{" "}
klopt.
</div>
</div>
) : (
<ul className="divide-y border rounded-lg overflow-hidden">
{(data as Band[] | null)?.map((b) => (
<li key={b.id} className="p-4 flex items-start justify-between gap-4">
<div className="min-w-0">
<div className="font-semibold truncate">{b.name}</div>
<div className="text-sm text-gray-600">
{b.genre} {b.stage}
</div>
</div>
<div className="shrink-0 text-sm text-gray-700">
{b.day} {b.start_time}
</div>
</li>
))}
</ul>
)}
</main>
);
}

View File

@@ -1,18 +0,0 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

View File

@@ -1,19 +0,0 @@
import "server-only";
import { createClient } from "@supabase/supabase-js";
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseServiceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!supabaseUrl) {
throw new Error("Missing env var: NEXT_PUBLIC_SUPABASE_URL");
}
if (!supabaseServiceRoleKey) {
throw new Error("Missing env var: SUPABASE_SERVICE_ROLE_KEY");
}
export const supabaseAdmin = createClient(supabaseUrl, supabaseServiceRoleKey, {
auth: { persistSession: false },
});

View File

@@ -1,6 +0,0 @@
import { createClient } from '@supabase/supabase-js'
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
export const supabase = createClient(supabaseUrl, supabaseAnonKey)

View File

@@ -1,8 +0,0 @@
import { createBrowserClient } from '@supabase/ssr'
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)
}

View File

@@ -1,23 +0,0 @@
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
export async function createClient() {
const cookieStore = await cookies()
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll()
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
)
},
},
}
)
}

View File

@@ -1,7 +0,0 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;

File diff suppressed because it is too large Load Diff

View File

@@ -1,33 +0,0 @@
{
"name": "polderfest-demo",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"@ai-sdk/openai": "^3.0.64",
"@ai-sdk/react": "^3.0.187",
"@supabase/supabase-js": "^2.106.0",
"ai": "^6.0.185",
"dotenv": "^17.4.2",
"next": "16.2.6",
"react": "19.2.4",
"react-dom": "19.2.4",
"zod": "^4.4.3"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.2.6",
"tailwindcss": "^4",
"tsx": "^4.22.3",
"typescript": "^5"
}
}

View File

@@ -1,7 +0,0 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

View File

@@ -1 +0,0 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 391 B

View File

@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

Before

Width:  |  Height:  |  Size: 128 B

View File

@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

Before

Width:  |  Height:  |  Size: 385 B

View File

@@ -1,272 +0,0 @@
/**
* Polderfest 2027 — seed script
* ----------------------------------------------------------
* Genereert 500 fictieve bands en zet ze in je Supabase `bands` tabel.
* Run:
* 1. Zorg dat `bands` tabel bestaat (zie schema.sql)
* 2. Vul .env.local met:
* SUPABASE_URL=https://<project>.supabase.co
* SUPABASE_SERVICE_ROLE_KEY=<service role key>
* 3. npm i @supabase/supabase-js dotenv tsx --save-dev
* 4. npx tsx seed-polderfest.ts
*
* Service role key is bewust nodig — alleen voor lokaal seeden.
* NIET committen, NIET in client gebruiken.
*/
import { createClient } from "@supabase/supabase-js";
import dotenv from "dotenv";
// Laad .env.local (i.p.v. default .env)
dotenv.config({ path: ".env.local" });
const SUPABASE_URL =
process.env.NEXT_PUBLIC_SUPABASE_URL ?? process.env.SUPABASE_URL;
const SUPABASE_SERVICE_ROLE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!SUPABASE_URL || !SUPABASE_SERVICE_ROLE_KEY) {
console.error(
"Ontbrekende env vars. Check .env.local:\n" +
" NEXT_PUBLIC_SUPABASE_URL=https://<project>.supabase.co\n" +
" SUPABASE_SERVICE_ROLE_KEY=<service role key>"
);
process.exit(1);
}
const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, {
auth: { persistSession: false },
});
// ────────────────────────────────────────────────────────────
// Deterministische random (zodat seed reproduceerbaar is)
// ────────────────────────────────────────────────────────────
let seed = 42;
function rand() {
seed = (seed * 9301 + 49297) % 233280;
return seed / 233280;
}
function pick<T>(arr: readonly T[]): T {
return arr[Math.floor(rand() * arr.length)];
}
function pickN<T>(arr: readonly T[], n: number): T[] {
const copy = [...arr];
const out: T[] = [];
for (let i = 0; i < n && copy.length; i++) {
out.push(copy.splice(Math.floor(rand() * copy.length), 1)[0]);
}
return out;
}
function range(min: number, max: number): number {
return Math.floor(rand() * (max - min + 1)) + min;
}
// ────────────────────────────────────────────────────────────
// Bouwstenen voor band-namen
// ────────────────────────────────────────────────────────────
const adjectives = [
"Lost", "Velvet", "Iron", "Neon", "Silent", "Wild", "Glass", "Paper", "Sleeping",
"Honest", "Crooked", "Bitter", "Sweet", "Drowsy", "Drowning", "Restless", "Sober",
"Midnight", "Morning", "Yellow", "Crimson", "Hollow", "Heavy", "Floating", "Slow",
"Burning", "Frozen", "Cardboard", "Plastic", "Analog", "Digital", "Forgotten",
] as const;
const nouns = [
"Tigers", "Wolves", "Horses", "Rabbits", "Mirrors", "Clouds", "Echoes", "Ghosts",
"Lights", "Roots", "Stones", "Foxes", "Riders", "Ships", "Tides", "Anchors",
"Maps", "Letters", "Postcards", "Radios", "Telegrams", "Diaries", "Highways",
"Cassettes", "Polaroids", "Cathedrals", "Stations", "Lanterns", "Compasses",
"Saturdays", "Tuesdays", "Mondays",
] as const;
const dutchPrefixes = [
"De", "Het", "Van der", "Polder", "Noord", "Zuid",
] as const;
const soloNamesFirst = [
"Sanne", "Joost", "Yara", "Lex", "Mila", "Tess", "Bram", "Lotte", "Ravi", "Imani",
"Marit", "Stijn", "Liva", "Noor", "Casper", "Anouk", "Mees", "Pien", "Daan", "Olivia",
"Niels", "Fenna", "Tygo", "Saar", "Cas", "Maud", "Roos", "Vince", "Lieke", "Floris",
] as const;
const soloNamesLast = [
"Van Dijk", "De Boer", "Visser", "Jansen", "Bakker", "Hendriks", "Mulder", "Smit",
"Peters", "De Vries", "Kuipers", "Brouwer", "Postma", "Hofman", "Van Loon",
] as const;
// ────────────────────────────────────────────────────────────
// Fest-velden
// ────────────────────────────────────────────────────────────
const genres = [
"Indie Rock", "Electronic", "Hip-Hop", "Jazz Fusion", "Folk", "Punk", "Soul",
"Ambient", "Disco-House", "Experimental", "Singer-Songwriter", "Synth-Pop",
"Garage Rock", "Neo-Soul", "Drum & Bass", "Afrobeat", "Dream Pop", "Post-Rock",
] as const;
const subGenresByGenre: Record<string, string[]> = {
"Indie Rock": ["Shoegaze", "Lo-Fi", "Math Rock", "Slowcore"],
"Electronic": ["Techno", "House", "IDM", "Glitch", "Trance"],
"Hip-Hop": ["Boom Bap", "Trap", "Lo-Fi", "Conscious"],
"Jazz Fusion": ["Funk Jazz", "Cosmic Jazz", "Nu-Jazz"],
"Folk": ["Anti-Folk", "Sea Shanty", "Modern Folk"],
"Punk": ["Post-Punk", "Hardcore", "Surf Punk"],
"Soul": ["Neo-Soul", "Northern Soul", "Funk"],
"Ambient": ["Drone", "New Age", "Field Recording"],
"Disco-House": ["Italo Disco", "Nu-Disco", "French House"],
"Experimental": ["Noise", "Sound Art", "Avantgarde"],
"Singer-Songwriter": ["Confessional", "Storytelling"],
"Synth-Pop": ["Vaporwave", "Italo", "Darkwave"],
"Garage Rock": ["Surf", "Power Pop"],
"Neo-Soul": ["Alt R&B", "Jazz-influenced"],
"Drum & Bass": ["Liquid", "Jungle", "Neurofunk"],
"Afrobeat": ["Afro-Fusion", "Highlife"],
"Dream Pop": ["Bedroom Pop", "Ethereal"],
"Post-Rock": ["Cinematic", "Math-influenced"],
};
const stages = [
"Main Stage", "Tent Stage", "Beach Stage", "Acoustic Bar", "Late Night Tent",
] as const;
const days = ["Vrijdag", "Zaterdag", "Zondag"] as const;
const timeSlots = [
"14:00", "15:30", "17:00", "18:30", "20:00", "21:30", "23:00", "00:30",
] as const;
const cities = [
"Amsterdam", "Rotterdam", "Utrecht", "Groningen", "Eindhoven", "Den Haag",
"Tilburg", "Maastricht", "Nijmegen", "Leeuwarden", "Arnhem", "Breda", "Haarlem",
"Zwolle", "Enschede", "Delft", "Den Bosch", "Apeldoorn",
] as const;
const tiers = ["headliner", "mid", "opener"] as const;
// ────────────────────────────────────────────────────────────
// Bio-fragmenten — combinatorisch zodat 500 bios uniek voelen
// ────────────────────────────────────────────────────────────
const bioOpenings = [
"Begonnen in een garage in",
"Ontstaan tijdens een blackout in",
"Een vriendengroep uit",
"Doorgebroken op het kleine podium van",
"Geboren uit een jam-sessie in",
"Een collectief van producers uit",
];
const bioMiddle = [
"experimenteert met analoge synths en gefluisterde lyrics",
"balanceert tussen melancholie en dansvloer-euforie",
"mixt traditionele samples met breakbeats",
"gebruikt veldopnames als ritmesectie",
"schrijft songs in Nederlands en Engels door elkaar",
"speelt instrumenten die ze grotendeels zelf hebben gebouwd",
"draait alleen optredens op locaties zonder Wi-Fi",
];
const bioEnding = [
"Debuut-EP verschijnt eind 2027.",
"Hun laatste album werd genomineerd voor de fictieve Edison Polder Award.",
"Polderfest is hun grootste festival tot nu toe.",
"Vorig jaar speelden ze nog in cafés, dit jaar op Stage B.",
"Spelen voor het eerst op een buitenpodium.",
"Beruchte live-show met 12 backing vocalists.",
];
// ────────────────────────────────────────────────────────────
// Namen genereren
// ────────────────────────────────────────────────────────────
function generateBandName(seedIdx: number): string {
const pattern = seedIdx % 4;
if (pattern === 0) {
return `${pick(adjectives)} ${pick(nouns)}`;
}
if (pattern === 1) {
return `${pick(dutchPrefixes)} ${pick(nouns)}`;
}
if (pattern === 2) {
return `${pick(soloNamesFirst)} ${pick(soloNamesLast)}`;
}
return `${pick(soloNamesFirst)} & The ${pick(nouns)}`;
}
function generateMembers(): string[] {
const count = range(1, 5);
const out: string[] = [];
for (let i = 0; i < count; i++) {
out.push(`${pick(soloNamesFirst)} ${pick(soloNamesLast)}`);
}
return out;
}
function generateBio(name: string): string {
return `${pick(bioOpenings)} ${pick(cities)}, ${name} ${pick(bioMiddle)}. ${pick(bioEnding)}`;
}
// ────────────────────────────────────────────────────────────
// Hoofdfunctie
// ────────────────────────────────────────────────────────────
async function runSeed() {
console.log("Genereren van 500 Polderfest bands...");
// Wipe bestaande data (optioneel)
await supabase.from("bands").delete().neq("id", 0);
const bands = [];
const usedNames = new Set<string>();
for (let i = 0; i < 500; i++) {
let name = generateBandName(i);
let attempts = 0;
while (usedNames.has(name) && attempts < 10) {
name = generateBandName(i + attempts * 7);
attempts++;
}
usedNames.add(name);
const genre = pick(genres);
const sub_genre = pick(subGenresByGenre[genre]);
const tier = pick(tiers);
const popularity = tier === "headliner" ? range(80, 100)
: tier === "mid" ? range(40, 79)
: range(10, 39);
const ticket_impact = tier === "headliner" ? range(25, 60)
: tier === "mid" ? range(5, 25)
: 0;
bands.push({
name,
genre,
sub_genre,
stage: pick(stages),
day: pick(days),
start_time: pick(timeSlots),
duration_min: tier === "headliner" ? range(75, 120)
: tier === "mid" ? range(45, 75)
: range(30, 45),
origin_city: pick(cities),
members: generateMembers(),
bio: generateBio(name),
tier,
popularity,
ticket_impact,
});
}
console.log("Schrijven naar Supabase in batches van 100...");
// Supabase insert in batches (single call van 500 kan timeouten)
for (let i = 0; i < bands.length; i += 100) {
const batch = bands.slice(i, i + 100);
const { error } = await supabase.from("bands").insert(batch);
if (error) {
console.error("Insert error op batch", i / 100, ":", error.message);
process.exit(1);
}
console.log(`${i + batch.length}/${bands.length}`);
}
console.log("Klaar! 500 Polderfest bands staan in Supabase.");
}
runSeed().catch((e) => {
console.error(e);
process.exit(1);
});

View File

@@ -1,34 +0,0 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}

View File

@@ -1,246 +0,0 @@
/**
* Polderfest Tool-Calling demo — reference snippets (AI SDK v6)
* --------------------------------------------------
* Dit zijn de tools die we live opbouwen tijdens Les 12.
* Matcht met de actuele stack van polderfest-demo:
* - ai@^6
* - @ai-sdk/openai@^3
* - @ai-sdk/react@^3
*
* Gebruik dit als naslag — niet 1-op-1 kopiëren tijdens demo.
* Plek in project: app/api/chat/route.ts
*/
import {
convertToModelMessages,
stepCountIs,
streamText,
tool,
type UIMessage,
} from "ai";
import { openai } from "@ai-sdk/openai";
import { createClient } from "@supabase/supabase-js";
import { z } from "zod";
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey =
process.env.SUPABASE_SERVICE_ROLE_KEY ??
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const supabase = createClient(supabaseUrl, supabaseKey);
// ────────────────────────────────────────────────────────────
// Tool 1: searchBands — filter op velden
// ────────────────────────────────────────────────────────────
const searchBands = tool({
description:
"Zoek bands in de Polderfest line-up. Filter op dag, stage, genre of tier. " +
"Gebruik dit als de gebruiker iets zoekt op één of meerdere criteria.",
inputSchema: z.object({
day: z
.enum(["Vrijdag", "Zaterdag", "Zondag"])
.optional()
.describe("Festival-dag"),
stage: z
.string()
.optional()
.describe("Bv. Main Stage, Tent Stage, Beach Stage, Acoustic Bar, Late Night Tent"),
genre: z
.string()
.optional()
.describe("Bv. Indie Rock, Electronic, Hip-Hop, Jazz Fusion"),
tier: z.enum(["headliner", "mid", "opener"]).optional(),
minPopularity: z
.number()
.min(1)
.max(100)
.optional()
.describe("Minimale populariteit (1-100)"),
}),
execute: async ({ day, stage, genre, tier, minPopularity }) => {
let q = supabase
.from("bands")
.select(
"id, name, genre, sub_genre, stage, day, start_time, " +
"origin_city, tier, popularity, bio",
);
if (day) q = q.eq("day", day);
if (stage) q = q.eq("stage", stage);
if (genre) q = q.eq("genre", genre);
if (tier) q = q.eq("tier", tier);
if (minPopularity) q = q.gte("popularity", minPopularity);
const { data, error } = await q.limit(20);
if (error) return { error: error.message };
return { count: data.length, bands: data };
},
});
// ────────────────────────────────────────────────────────────
// Tool 2: getBandByName — exacte lookup
// ────────────────────────────────────────────────────────────
const getBandByName = tool({
description:
"Haal alle details op van één specifieke band, inclusief members en bio. " +
"Gebruik dit als de gebruiker naar een specifieke band vraagt.",
inputSchema: z.object({
name: z.string().describe("Exacte band-naam"),
}),
execute: async ({ name }) => {
const { data, error } = await supabase
.from("bands")
.select("*")
.ilike("name", name)
.single();
if (error) return { error: `Band '${name}' niet gevonden.` };
return data;
},
});
// ────────────────────────────────────────────────────────────
// Tool 3: getStats — aggregate over alle bands
// ────────────────────────────────────────────────────────────
const getStats = tool({
description:
"Geef statistieken over de festival-line-up — totaal aantal bands, " +
"verdeling per genre, per dag, of per stage. Geen filters — overzicht.",
inputSchema: z.object({
groupBy: z
.enum(["genre", "day", "stage", "tier"])
.describe("Hoe te groeperen"),
}),
execute: async ({ groupBy }) => {
const { data, error } = await supabase.from("bands").select(groupBy);
if (error) return { error: error.message };
const counts: Record<string, number> = {};
for (const row of data) {
const key = row[groupBy as keyof typeof row] as string;
counts[key] = (counts[key] ?? 0) + 1;
}
return { total: data.length, counts };
},
});
// ────────────────────────────────────────────────────────────
// Tool 4: getScheduleByDay — slot-overzicht
// ────────────────────────────────────────────────────────────
const getScheduleByDay = tool({
description:
"Geef het volledige tijdschema voor één festival-dag. " +
"Bands gesorteerd op tijdslot. Handig voor 'wat speelt er om 22:00?'",
inputSchema: z.object({
day: z.enum(["Vrijdag", "Zaterdag", "Zondag"]),
stage: z
.string()
.optional()
.describe("Optioneel filteren op specifieke stage"),
}),
execute: async ({ day, stage }) => {
let q = supabase
.from("bands")
.select("name, stage, start_time, duration_min, tier")
.eq("day", day);
if (stage) q = q.eq("stage", stage);
const { data, error } = await q.order("start_time", { ascending: true });
if (error) return { error: error.message };
return data;
},
});
// ────────────────────────────────────────────────────────────
// Tool 5 (write): addFavorite — user_favorites tabel
// ────────────────────────────────────────────────────────────
const addFavorite = tool({
description:
"Voeg een band toe aan de favorieten van de gebruiker. " +
"Gebruik alleen als de gebruiker expliciet vraagt 'voeg X toe aan mijn favorieten'.",
inputSchema: z.object({
userEmail: z.string().email().describe("Email van de gebruiker"),
bandName: z.string().describe("Exacte band-naam"),
}),
execute: async ({ userEmail, bandName }) => {
const { data: band } = await supabase
.from("bands")
.select("id")
.ilike("name", bandName)
.single();
if (!band) return { error: `Band '${bandName}' niet gevonden.` };
const { error } = await supabase
.from("user_favorites")
.insert({ user_email: userEmail, band_id: band.id });
if (error) return { error: error.message };
return { success: true, bandName, bandId: band.id };
},
});
// ────────────────────────────────────────────────────────────
// Tool 6 (read): listFavorites
// ────────────────────────────────────────────────────────────
const listFavorites = tool({
description: "Geef de favoriete bands van de gebruiker.",
inputSchema: z.object({
userEmail: z.string().email(),
}),
execute: async ({ userEmail }) => {
const { data, error } = await supabase
.from("user_favorites")
.select("bands(name, genre, day, start_time, stage)")
.eq("user_email", userEmail);
if (error) return { error: error.message };
return data?.map((r) => r.bands) ?? [];
},
});
// ────────────────────────────────────────────────────────────
// De chat-route met alle tools
// ────────────────────────────────────────────────────────────
export async function POST(req: Request) {
try {
const { messages }: { messages: UIMessage[] } = await req.json();
const system = `Je bent een festival-assistent voor Polderfest 2027.
Je hebt toegang tot tools om de bands-database te bevragen. Gebruik altijd
tools voor concrete vragen — verzin nooit data. Antwoord beknopt en in
het Nederlands.
Tips:
- Voor "welke bands op X?" → searchBands
- Voor specifieke band → getBandByName
- Voor "hoeveel" / "verdeling" → getStats
- Voor tijdschema → getScheduleByDay
- Voor favorieten → addFavorite / listFavorites
Als een tool een error returnt, leg dat netjes uit aan de gebruiker.`;
const result = streamText({
model: openai("gpt-4o-mini"),
system,
messages: await convertToModelMessages(messages),
tools: {
searchBands,
getBandByName,
getStats,
getScheduleByDay,
addFavorite,
listFavorites,
},
stopWhen: stepCountIs(5),
});
return result.toUIMessageStreamResponse({
onError(error: unknown) {
if (error == null) return "unknown error";
if (typeof error === "string") return error;
if (error instanceof Error) return error.message;
return JSON.stringify(error);
},
});
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
return Response.json(
{ error: "Chat route failed", details: message },
{ status: 500 },
);
}
}