283 lines
6.9 KiB
Markdown
283 lines
6.9 KiB
Markdown
# Les 18 — Lesopdracht
|
|
## Kies één modaliteit, bouw een mini-demo
|
|
|
|
**Duur:** 30 min in-class
|
|
|
|
---
|
|
|
|
## Doel
|
|
|
|
Pak één van de modaliteiten uit deze les en bouw daar een minimale werkende demo van. Voice, vision, image gen, of local LLM — jouw keuze.
|
|
|
|
---
|
|
|
|
## Opties
|
|
|
|
### Optie 1 — Voice transcriptie
|
|
|
|
Bouw een mic-button + transcribe. Werkende voice → text in een Next.js page.
|
|
|
|
```typescript
|
|
// app/voice/page.tsx
|
|
"use client";
|
|
import { useState, useRef } from "react";
|
|
|
|
export default function VoicePage() {
|
|
const [text, setText] = useState("");
|
|
const [recording, setRecording] = useState(false);
|
|
const recorder = useRef<MediaRecorder | null>(null);
|
|
const chunks = useRef<Blob[]>([]);
|
|
|
|
async function start() {
|
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
recorder.current = new MediaRecorder(stream);
|
|
recorder.current.ondataavailable = (e) => chunks.current.push(e.data);
|
|
recorder.current.onstop = async () => {
|
|
const blob = new Blob(chunks.current, { type: "audio/webm" });
|
|
chunks.current = [];
|
|
const fd = new FormData();
|
|
fd.append("audio", blob);
|
|
const res = await fetch("/api/transcribe", { method: "POST", body: fd });
|
|
const { text } = await res.json();
|
|
setText(text);
|
|
};
|
|
recorder.current.start();
|
|
setRecording(true);
|
|
}
|
|
|
|
function stop() {
|
|
recorder.current?.stop();
|
|
setRecording(false);
|
|
}
|
|
|
|
return (
|
|
<main className="p-8">
|
|
<button onClick={recording ? stop : start} className="bg-blue-600 text-white px-6 py-3 rounded-full">
|
|
{recording ? "Stop" : "Start opnemen"}
|
|
</button>
|
|
<p className="mt-6">{text}</p>
|
|
</main>
|
|
);
|
|
}
|
|
```
|
|
|
|
`app/api/transcribe/route.ts`:
|
|
```typescript
|
|
import { experimental_transcribe as transcribe } from "ai";
|
|
import { openai } from "@ai-sdk/openai";
|
|
|
|
export async function POST(req: Request) {
|
|
const fd = await req.formData();
|
|
const audio = fd.get("audio") as Blob;
|
|
const result = await transcribe({
|
|
model: openai.transcription("whisper-1"),
|
|
audio: new Uint8Array(await audio.arrayBuffer()),
|
|
});
|
|
return Response.json({ text: result.text });
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### Optie 2 — Vision foto-analyse
|
|
|
|
Bouw een upload-form + analyse. User upload foto → AI beschrijft.
|
|
|
|
```typescript
|
|
// app/vision/page.tsx
|
|
"use client";
|
|
import { useState } from "react";
|
|
|
|
export default function VisionPage() {
|
|
const [desc, setDesc] = useState("");
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
async function analyze(e: React.ChangeEvent<HTMLInputElement>) {
|
|
const file = e.target.files?.[0];
|
|
if (!file) return;
|
|
setLoading(true);
|
|
const fd = new FormData();
|
|
fd.append("image", file);
|
|
const res = await fetch("/api/analyze", { method: "POST", body: fd });
|
|
const { description } = await res.json();
|
|
setDesc(description);
|
|
setLoading(false);
|
|
}
|
|
|
|
return (
|
|
<main className="p-8">
|
|
<input type="file" accept="image/*" onChange={analyze} />
|
|
{loading && <p>Bezig...</p>}
|
|
<p className="mt-6">{desc}</p>
|
|
</main>
|
|
);
|
|
}
|
|
```
|
|
|
|
`app/api/analyze/route.ts`:
|
|
```typescript
|
|
import { generateText } from "ai";
|
|
import { openai } from "@ai-sdk/openai";
|
|
|
|
export async function POST(req: Request) {
|
|
const fd = await req.formData();
|
|
const image = fd.get("image") as File;
|
|
const buffer = await image.arrayBuffer();
|
|
|
|
const result = await generateText({
|
|
model: openai("gpt-4o"),
|
|
messages: [{
|
|
role: "user",
|
|
content: [
|
|
{ type: "text", text: "Beschrijf wat er op deze foto staat in 3 zinnen." },
|
|
{ type: "image", image: new Uint8Array(buffer) },
|
|
],
|
|
}],
|
|
});
|
|
|
|
return Response.json({ description: result.text });
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### Optie 3 — Image generation
|
|
|
|
Bouw een prompt-input → generate → preview.
|
|
|
|
```typescript
|
|
// app/generate/page.tsx
|
|
"use client";
|
|
import { useState } from "react";
|
|
|
|
export default function GeneratePage() {
|
|
const [prompt, setPrompt] = useState("");
|
|
const [imageUrl, setImageUrl] = useState("");
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
async function generate() {
|
|
setLoading(true);
|
|
const res = await fetch("/api/generate", {
|
|
method: "POST",
|
|
body: JSON.stringify({ prompt }),
|
|
});
|
|
const { image } = await res.json();
|
|
setImageUrl(`data:image/png;base64,${image}`);
|
|
setLoading(false);
|
|
}
|
|
|
|
return (
|
|
<main className="p-8 max-w-2xl mx-auto">
|
|
<textarea value={prompt} onChange={(e) => setPrompt(e.target.value)}
|
|
placeholder="Beschrijf je afbeelding..." rows={3}
|
|
className="w-full border p-2 rounded" />
|
|
<button onClick={generate} disabled={loading}
|
|
className="mt-3 bg-blue-600 text-white px-4 py-2 rounded">
|
|
{loading ? "..." : "Generate"}
|
|
</button>
|
|
{imageUrl && <img src={imageUrl} alt="" className="mt-6 rounded" />}
|
|
</main>
|
|
);
|
|
}
|
|
```
|
|
|
|
`app/api/generate/route.ts`:
|
|
```typescript
|
|
import { experimental_generateImage as generateImage } from "ai";
|
|
import { fal } from "@ai-sdk/fal";
|
|
|
|
export async function POST(req: Request) {
|
|
const { prompt } = await req.json();
|
|
const result = await generateImage({
|
|
model: fal.image("fal-ai/flux/schnell"),
|
|
prompt,
|
|
size: "1024x1024",
|
|
});
|
|
return Response.json({ image: result.image.base64 });
|
|
}
|
|
```
|
|
|
|
`pnpm add @ai-sdk/fal` — gratis tier op fal.ai.
|
|
|
|
---
|
|
|
|
### Optie 4 — Local LLM met Ollama
|
|
|
|
Run llama lokaal, chat ermee via je app.
|
|
|
|
```bash
|
|
brew install ollama
|
|
ollama pull llama4:8b
|
|
ollama serve
|
|
```
|
|
|
|
```bash
|
|
pnpm add ollama-ai-provider
|
|
```
|
|
|
|
```typescript
|
|
// app/local/page.tsx + /api/local-chat
|
|
import { createOllama } from "ollama-ai-provider";
|
|
import { streamText } from "ai";
|
|
|
|
const ollama = createOllama();
|
|
|
|
export async function POST(req: Request) {
|
|
const { messages } = await req.json();
|
|
const result = streamText({
|
|
model: ollama("llama4:8b"),
|
|
messages,
|
|
});
|
|
return result.toUIMessageStreamResponse();
|
|
}
|
|
```
|
|
|
|
Frontend kan dezelfde `useChat` gebruiken als in Les 12.
|
|
|
|
---
|
|
|
|
## Eisen
|
|
|
|
Kies één optie. Aan het eind:
|
|
|
|
- [ ] Demo werkt lokaal
|
|
- [ ] Eén feature compleet (transcribe, analyze, generate, of chat)
|
|
- [ ] Geen errors in console
|
|
- [ ] Optioneel: gedeeld in Teams met screenshot
|
|
|
|
---
|
|
|
|
## Tijdsindeling (30 min)
|
|
|
|
| Stap | Tijd |
|
|
|------|------|
|
|
| 1 — Project setup | 5 min |
|
|
| 2 — Backend route | 10 min |
|
|
| 3 — Frontend page | 10 min |
|
|
| 4 — Test + debug | 5 min |
|
|
|
|
---
|
|
|
|
## Veelvoorkomende problemen
|
|
|
|
| Symptoom | Oplossing |
|
|
|----------|-----------|
|
|
| `MediaRecorder undefined` | Browser support — Chrome werkt, Safari iets anders |
|
|
| Fal API key | Sign up + create key — gratis |
|
|
| Ollama "connection refused" | `ollama serve` runnen + check `:11434` |
|
|
| Vision error | Image te groot? Resize to <4MB |
|
|
| Transcribe error op localhost | Gebruik buffer (Uint8Array) niet Blob direct |
|
|
|
|
---
|
|
|
|
## Klaar?
|
|
|
|
Dat was de laatste lesopdracht van de leerlijn. Voor je eindopdracht — wat je nu kunt:
|
|
|
|
- Multi-modal features toevoegen (voice/vision)
|
|
- Lokale modellen voor privacy
|
|
- Image generation voor branding
|
|
- Edge AI voor performance
|
|
|
|
Veel succes!
|