Schulungs-Vorlage auf neues App-Template umgestellt

This commit is contained in:
Admin committed 2026-10-30 11:13:39 +00:00
1 parent 2dfa403641
commit 46efd2d9d0
17 files changed
+162 -414

No files matched your search

-52
View File
@@ -1,52 +0,0 @@
import { streamText, convertToModelMessages, type UIMessage } from "ai";
import { getModel } from "@/lib/ai/model";
// Streaming-Chat-Endpoint (Muster C). Wird von useChat (Default /api/chat) aufgerufen.
//
// ACHTUNG: Dieser Endpoint hat KEINEN Login. Wer die Adresse der App kennt, kann hier
// auf Kosten des hinterlegten ANTHROPIC_API_KEY chatten (offener KI-Proxy).
// Deshalb ist er standardmäßig AUS und läuft nur mit CHAT_ENABLED=true.
// Nie mit echtem Key öffentlich veröffentlichen, solange die App keinen Schutz hat
// (Login, Zugangscode). Die Grenzen unten dämpfen Missbrauch nur, sie verhindern ihn nicht.
const MAX_MESSAGES = 40;
const MAX_REQUEST_CHARS = 60000;
const MAX_OUTPUT_TOKENS = 1024;
function parseJson(text: string): unknown {
try {
return JSON.parse(text);
} catch {
return null;
}
}
export async function POST(req: Request) {
if (process.env.CHAT_ENABLED !== "true") {
return new Response(
"KI-Chat ist deaktiviert. Zum Einschalten CHAT_ENABLED=true setzen (nur mit Schutz öffentlich betreiben).",
{ status: 403 },
);
}
const raw = await req.text();
if (raw.length > MAX_REQUEST_CHARS) {
return new Response("Anfrage zu groß.", { status: 413 });
}
const body = parseJson(raw) as { messages?: unknown } | null;
const messages = body?.messages;
if (!Array.isArray(messages) || messages.length === 0) {
return new Response("Ungültige Anfrage.", { status: 400 });
}
if (messages.length > MAX_MESSAGES) {
return new Response("Der Chat ist zu lang. Bitte die Seite neu laden.", { status: 413 });
}
const result = streamText({
model: getModel(),
messages: await convertToModelMessages(messages as UIMessage[]),
maxOutputTokens: MAX_OUTPUT_TOKENS,
});
return result.toUIMessageStreamResponse();
}
-59
View File
@@ -1,59 +0,0 @@
"use client";
import { useState } from "react";
import { useChat } from "@ai-sdk/react";
// Muster C: KI-Chat mit Token-Streaming. Ruft POST /api/chat auf.
// Achtung: /api/chat hat keinen Login und ist nur mit CHAT_ENABLED=true aktiv (siehe route.ts).
// In der Schulung bleibt der Chat aus. KI läuft über einen n8n-Workflow.
export default function ChatPage() {
const { messages, sendMessage, status, error } = useChat();
const [input, setInput] = useState("");
const busy = status === "submitted" || status === "streaming";
function onSubmit(e: React.FormEvent) {
e.preventDefault();
if (!input.trim() || busy) return;
sendMessage({ text: input });
setInput("");
}
return (
<div className="flex flex-col gap-4">
<h1 className="ep-ueberschrift text-3xl">KI-Chat (Muster C)</h1>
<p className="text-sm text-ink-muted">
Streamt Antworten von Claude. Braucht <code>ANTHROPIC_API_KEY</code> und <code>CHAT_ENABLED=true</code>. Ohne
Login nie mit echtem Key öffentlich veröffentlichen.
</p>
<div className="flex flex-col gap-3">
{messages.map((message) => (
<div
key={message.id}
className={
message.role === "user"
? "self-end bg-accent px-3 py-2 text-on-accent"
: "self-start border border-hairline bg-surface px-3 py-2"
}
>
{message.parts.map((part, i) => (part.type === "text" ? <span key={i}>{part.text}</span> : null))}
</div>
))}
</div>
{error && <div className="ep-fehler">{error.message}</div>}
<form onSubmit={onSubmit} className="flex gap-2">
<input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Frag mich etwas …"
className="ep-feld flex-1"
/>
<button type="submit" disabled={busy} className="ep-knopf">
Senden
</button>
</form>
</div>
);
}
+1 -5
View File
@@ -4,12 +4,8 @@ import "./globals.css";
import { EpKopf } from "@/components/EpKopf";
import { EpFuss } from "@/components/EpFuss";
import { APP_BESCHREIBUNG, APP_TITEL } from "@/lib/app";
import { Inter } from "next/font/google";
import { cn } from "@/lib/utils";
const inter = Inter({subsets:['latin'],variable:'--font-sans'});
// Hausschrift von ep-reisen.de (Lato, freie Lizenz OFL). Die Dateien liegen im Projekt,
// damit der Build ohne Internet klappt. Lato hat keine Stärke 600, font-semibold erscheint fett.
const lato = localFont({
@@ -37,7 +33,7 @@ export const viewport: Viewport = {
// E&P-Layout für jede Seite: Kopf mit Logo, Inhalt, Fuß. Seiten liefern nur ihren Inhalt, kein eigenes <main>.
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="de" className={cn("font-sans", inter.variable)}>
<html lang="de" className={cn("font-sans", lato.variable)}>
<body className="flex min-h-screen flex-col bg-ground text-ink">
<a href="#inhalt" className="skip-link">
Zum Inhalt springen
+12 -71
View File
@@ -1,76 +1,17 @@
"use client";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { APP_BESCHREIBUNG, APP_TITEL } from "@/lib/app";
import { useState } from "react";
import { postWebhook } from "@/lib/n8n";
type HelloResponse = { message?: string };
// Pfad des eigenen Webhooks mit Kürzel, zum Beispiel "anna-hello" (ohne /webhook/).
// In der E&P-n8n gehört jeder Pfad genau einem Workflow. Ohne Angabe ruft die Seite nichts auf.
const HELLO_PATH = (process.env.NEXT_PUBLIC_HELLO_PATH || "").trim();
// Beispielseite im E&P-Layout. Kopf und Fuß kommen aus app/layout.tsx,
// Farben und Bausteine (ep-karte, ep-feld, ep-knopf, ep-cta, ep-hinweis, ep-fehler) aus app/globals.css.
// Startseite im E&P-Layout. Kopf und Fuß kommen aus app/layout.tsx,
// Farben und Bausteine (ep-karte, ep-ueberschrift, …) aus app/globals.css.
export default function Home() {
const [name, setName] = useState("");
const [answer, setAnswer] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
async function onSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
setAnswer(null);
if (!HELLO_PATH) {
setError(
"NEXT_PUBLIC_HELLO_PATH fehlt. Beim ersten Veröffentlichen den eigenen Pfad mitgeben, zum Beispiel anna-hello.",
);
return;
}
setLoading(true);
try {
const res = await postWebhook<HelloResponse>(HELLO_PATH, { name });
setAnswer(res.message ?? JSON.stringify(res));
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
}
return (
<div className="flex flex-col gap-6">
<div>
<h1 className="ep-ueberschrift text-3xl">Hallo aus n8n</h1>
<p className="mt-4 text-ink-muted">
Das Formular ruft einen n8n-Webhook deines eigenen Workflows auf (Vorlage <code>hello-webhook.json</code>,
Pfad <code>/webhook/{HELLO_PATH || "anna-hello"}</code>).
</p>
</div>
<form onSubmit={onSubmit} className="ep-karte flex flex-col gap-3">
<label htmlFor="name" className="ep-label">
Dein Name
</label>
<input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Max"
className="ep-feld"
required
/>
<button type="submit" disabled={loading} className="ep-knopf ep-cta self-start">
{loading ? "Sende …" : "An n8n senden"}
</button>
</form>
{answer && <div className="ep-hinweis">{answer}</div>}
{error && (
<div role="alert" className="ep-fehler">
{error}
</div>
)}
</div>
<Card className="ep-karte rounded-none shadow-none ring-0">
<CardHeader className="px-0">
<h1 className="ep-ueberschrift text-3xl">{APP_TITEL}</h1>
</CardHeader>
<CardContent className="px-0">
<p className="text-ink-muted">{APP_BESCHREIBUNG}</p>
</CardContent>
</Card>
);
}