final lessons
This commit is contained in:
68
Les11-AI-SDK/lesbestanden/page.tsx
Normal file
68
Les11-AI-SDK/lesbestanden/page.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* 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 { 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"
|
||||
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>
|
||||
);
|
||||
}
|
||||
BIN
Les11-AI-SDK/lesbestanden/polderfest-demo.zip
Normal file
BIN
Les11-AI-SDK/lesbestanden/polderfest-demo.zip
Normal file
Binary file not shown.
61
Les11-AI-SDK/lesbestanden/route.ts
Normal file
61
Les11-AI-SDK/lesbestanden/route.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* 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 { 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 alle bands op uit Supabase
|
||||
const { data: bands, error } = await supabase.from("bands").select("*");
|
||||
if (error) throw error;
|
||||
|
||||
// 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})`,
|
||||
)
|
||||
.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.`;
|
||||
|
||||
// 4. Stream naar OpenAI
|
||||
const result = streamText({
|
||||
model: openai("gpt-4o-mini"),
|
||||
system,
|
||||
messages,
|
||||
});
|
||||
|
||||
return result.toDataStreamResponse();
|
||||
}
|
||||
42
Les11-AI-SDK/lesbestanden/schema.sql
Normal file
42
Les11-AI-SDK/lesbestanden/schema.sql
Normal file
@@ -0,0 +1,42 @@
|
||||
-- Polderfest 2027 — Supabase schema
|
||||
-- Run dit in Supabase SQL Editor voor je het seed script gebruikt.
|
||||
|
||||
create table if not exists bands (
|
||||
id bigserial primary key,
|
||||
name text not null,
|
||||
genre text not null,
|
||||
sub_genre text,
|
||||
stage text not null,
|
||||
day text not null check (day in ('Vrijdag','Zaterdag','Zondag')),
|
||||
start_time text not null, -- "21:30"
|
||||
duration_min int not null default 60,
|
||||
origin_city text,
|
||||
members text[],
|
||||
bio text,
|
||||
tier text check (tier in ('headliner','mid','opener')),
|
||||
popularity int check (popularity between 1 and 100),
|
||||
ticket_impact numeric(6,2), -- bijdrage aan ticketprijs als extra
|
||||
created_at timestamp default now()
|
||||
);
|
||||
|
||||
-- Maak indexen voor de vragen die we vaak gaan stellen
|
||||
create index if not exists idx_bands_day on bands(day);
|
||||
create index if not exists idx_bands_stage on bands(stage);
|
||||
create index if not exists idx_bands_genre on bands(genre);
|
||||
create index if not exists idx_bands_tier on bands(tier);
|
||||
|
||||
-- RLS (we lezen public, geen edits voor anon)
|
||||
alter table bands enable row level security;
|
||||
|
||||
create policy "Bands zijn publiek leesbaar"
|
||||
on bands for select
|
||||
using (true);
|
||||
|
||||
-- Optioneel: tabel voor straks (tool calling demo in Les 12)
|
||||
create table if not exists user_favorites (
|
||||
id bigserial primary key,
|
||||
user_email text not null,
|
||||
band_id bigint not null references bands(id) on delete cascade,
|
||||
created_at timestamp default now(),
|
||||
unique(user_email, band_id)
|
||||
);
|
||||
272
Les11-AI-SDK/lesbestanden/seed-polderfest.ts
Normal file
272
Les11-AI-SDK/lesbestanden/seed-polderfest.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
Reference in New Issue
Block a user