add les 12

This commit is contained in:
2026-05-21 08:52:47 +02:00
parent 634789e615
commit eb1ba2e28d
42 changed files with 11012 additions and 8 deletions

View File

@@ -0,0 +1,97 @@
/**
* 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.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -0,0 +1,26 @@
@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

@@ -0,0 +1,33 @@
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

@@ -0,0 +1,89 @@
/**
* 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

@@ -0,0 +1,61 @@
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>
);
}