Next.js Chat-App

Eine datenschutzkonforme Chat-App mit Next.js und Noirdoc erstellen.

Referenzanwendung

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}: &quot;{e.text}&quot; (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();