Next.js Chat-App
Eine datenschutzkonforme Chat-App mit Next.js und Noirdoc erstellen.
Dies ist eine vollstaendige Beispielanwendung, die die Noirdoc-Integration in einer Next.js-App demonstriert. Fuer die grundlegende Provider-Integration siehe die Integrationsanleitungen.
Überblick
Erstellen Sie eine datenschutzkonforme Chat-Anwendung mit Next.js und Noirdoc, basierend auf Mustern aus der noirdoc-chat-Referenzimplementierung. Der Ansatz verwendet serverseitige API-Routen mit rohem fetch, um Antworten vom Noirdoc-Proxy zu streamen, einschliesslich eines benutzerdefinierten SSE-Events für Pseudonymisierungsdetails.
Umgebungsvariablen
Speichern Sie Ihre Noirdoc-Konfiguration in .env.local:
NOIRDOC_PROXY_URL=https://api.noirdoc.de
NOIRDOC_PROXY_KEY=px-your-noirdoc-key
Halten Sie diese serverseitig — der Proxy-Key darf niemals an den Browser gelangen.
Serverseitige API-Route
Erstellen Sie einen Route Handler, der Chat-Nachrichten durch den Noirdoc-Proxy mit aktiviertem Streaming weiterleitet.
// app/api/chat/route.ts
import { NextRequest } from "next/server";
export async function POST(req: NextRequest) {
const { messages, model } = await req.json();
const response = await fetch(
`${process.env.NOIRDOC_PROXY_URL}/v1/messages`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": process.env.NOIRDOC_PROXY_KEY!,
"anthropic-version": "2023-06-01",
},
body: JSON.stringify({
model: model || "claude-sonnet-4-6",
max_tokens: 4096,
stream: true,
messages,
}),
}
);
if (!response.ok) {
const error = await response.text();
return new Response(error, { status: response.status });
}
return new Response(response.body, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
}
Client-seitiges Stream-Parsing
Parsen Sie den SSE-Stream auf dem Client, um Text-Deltas und Pseudonymisierungs-Events zu extrahieren.
// lib/stream.ts
export interface PseudonymizationResult {
original: string;
pseudonymized: string;
entities: Array<{
entity_type: string;
text: string;
start: number;
end: number;
score: number;
}>;
mapping: Record<string, string>;
}
export interface StreamCallbacks {
onText: (text: string) => void;
onPseudonymization?: (result: PseudonymizationResult) => void;
onDone: () => void;
}
export async function parseSSEStream(
response: Response,
callbacks: StreamCallbacks
) {
const reader = response.body!.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
let eventType = "";
for (const line of lines) {
if (line.startsWith("event: ")) {
eventType = line.slice(7).trim();
} else if (line.startsWith("data: ")) {
const data = line.slice(6);
if (data === "[DONE]") {
callbacks.onDone();
return;
}
try {
const parsed = JSON.parse(data);
if (eventType === "pseudonymization") {
callbacks.onPseudonymization?.(parsed);
} else if (parsed.type === "content_block_delta") {
callbacks.onText(parsed.delta?.text || "");
}
} catch {
// Nicht-JSON-Zeilen ueberspringen
}
eventType = "";
}
}
}
callbacks.onDone();
}
Chat-Komponente
Verbinden Sie den Stream-Parser mit einer React-Komponente.
// components/Chat.tsx
"use client";
import { useState, useCallback } from "react";
import { parseSSEStream, PseudonymizationResult } from "@/lib/stream";
interface Message {
role: "user" | "assistant";
content: string;
}
export default function Chat() {
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState("");
const [isStreaming, setIsStreaming] = useState(false);
const [pseudonymization, setPseudonymization] =
useState<PseudonymizationResult | null>(null);
const sendMessage = useCallback(async () => {
if (!input.trim() || isStreaming) return;
const userMessage: Message = { role: "user", content: input };
const updatedMessages = [...messages, userMessage];
setMessages([...updatedMessages, { role: "assistant", content: "" }]);
setInput("");
setIsStreaming(true);
const response = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages: updatedMessages }),
});
let assistantContent = "";
await parseSSEStream(response, {
onText: (text) => {
assistantContent += text;
setMessages([
...updatedMessages,
{ role: "assistant", content: assistantContent },
]);
},
onPseudonymization: (result) => {
setPseudonymization(result);
},
onDone: () => {
setIsStreaming(false);
},
});
}, [input, messages, isStreaming]);
return (
<div>
{messages.map((msg, i) => (
<div key={i} className={msg.role === "user" ? "text-right" : ""}>
<p>{msg.content}</p>
</div>
))}
{pseudonymization && (
<details>
<summary>
PII erkannt: {pseudonymization.entities.length} Entitaeten
</summary>
<ul>
{pseudonymization.entities.map((e, i) => (
<li key={i}>
{e.entity_type}: "{e.text}" (Score: {e.score.toFixed(2)})
</li>
))}
</ul>
</details>
)}
<input
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && sendMessage()}
placeholder="Nachricht eingeben..."
/>
<button onClick={sendMessage} disabled={isStreaming}>
Senden
</button>
</div>
);
}
Das Pseudonymisierungs-SSE-Event
Waehrend des Streamings sendet Noirdoc ein benutzerdefiniertes pseudonymization SSE-Event vor den Hauptinhalt-Events:
event: pseudonymization
data: {"original":"...","pseudonymized":"...","entities":[...],"mapping":{"<<PERSON_1>>":"Max Mustermann"}}
Das mapping-Feld ist ein Dictionary von Pseudonym-Tokens zu Originalwerten. Verwenden Sie es, um Benutzern anzuzeigen, welche Daten erkannt wurden, Audit-Trails aufzubauen oder Annotationen in der Chat-UI anzuzeigen.
Verwendung mit OpenAI statt Anthropic
Um OpenAI-Modelle zu verwenden, aendern Sie die API-Route auf den Chat-Completions-Endpunkt:
const response = await fetch(
`${process.env.NOIRDOC_PROXY_URL}/v1/chat/completions`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.NOIRDOC_PROXY_KEY}`,
},
body: JSON.stringify({
model: "gpt-5.4-mini",
stream: true,
messages,
}),
}
);
Eigenstaendige Pseudonymisierung
Sie koennen den Pseudonymisierungs-Endpunkt auch direkt aufrufen für Anwendungsfaelle ohne LLM:
const result = await fetch(
`${process.env.NOIRDOC_PROXY_URL}/v1/pseudonymize`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.NOIRDOC_PROXY_KEY}`,
},
body: JSON.stringify({
text: "Max Mustermann, E-Mail max@example.com",
language: "de",
}),
}
);
const { pseudonymized, entities, mapping } = await result.json();