feat: add RAG chat widget for recruiter assistant

This commit is contained in:
2026-02-25 15:17:24 +01:00
parent 4a5155eb1e
commit 3ad4a7ab85
3 changed files with 173 additions and 0 deletions
+2
View File
@@ -1,4 +1,5 @@
import type { Metadata } from "next";
import ChatWidget from "@/components/ChatWidget";
import { Varela_Round } from "next/font/google";
import "./globals.css";
@@ -31,6 +32,7 @@ export default function RootLayout({
className={`${varelaRound.variable} antialiased font-varela`}
>
{children}
<ChatWidget />
</body>
</html>
);
+171
View File
@@ -0,0 +1,171 @@
"use client";
import Image from "next/image";
import { useState, useRef, useEffect } from "react";
interface Message {
role: "user" | "assistant";
content: string;
}
const RAG_API_URL = "https://ai.apply4jobs.de/api/v1/chat/completions";
export default function ChatWidget() {
const [isOpen, setIsOpen] = useState(false);
const [messages, setMessages] = useState<Message[]>([
{
role: "assistant",
content:
"Hallo! Ich bin Sebastians KI-Assistent. Stellen Sie mir gerne Fragen zu seiner Berufserfahrung, seinen Fähigkeiten oder Projekten. 👋\n\nHello! I'm Sebastian's AI assistant. Feel free to ask me about his professional experience, skills or projects.",
},
]);
const [input, setInput] = useState("");
const [isLoading, setIsLoading] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages, isLoading]);
const sendMessage = async () => {
if (!input.trim() || isLoading) return;
const userMessage: Message = { role: "user", content: input.trim() };
setMessages((prev) => [...prev, userMessage]);
setInput("");
setIsLoading(true);
try {
const response = await fetch(RAG_API_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: "sebastian-rag",
messages: [...messages, userMessage],
stream: false,
}),
});
const data = await response.json();
const assistantContent =
data?.choices?.[0]?.message?.content ?? "Entschuldigung, ich konnte keine Antwort generieren.";
setMessages((prev) => [
...prev,
{ role: "assistant", content: assistantContent },
]);
} catch {
setMessages((prev) => [
...prev,
{
role: "assistant",
content: "Es ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut.",
},
]);
} finally {
setIsLoading(false);
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
};
return (
<>
{/* Chat Window */}
{isOpen && (
<div className="fixed bottom-24 right-6 z-50 w-96 max-w-[calc(100vw-2rem)] bg-white rounded-2xl shadow-2xl flex flex-col overflow-hidden border border-gray-200"
style={{ height: "520px" }}>
{/* Header */}
<div className="bg-gradient-to-r from-blue-600 to-blue-700 px-4 py-3 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-full overflow-hidden bg-white/20 flex-shrink-0">
<Image src="/img/Bewerbungsfoto_2025.jpg" alt="Sebastian Fröhlich" width={36} height={36} className="w-full h-full object-cover object-center" />
</div>
<div>
<p className="text-white font-semibold text-sm">Sebastian Fröhlich</p>
<p className="text-blue-200 text-xs">KI-Assistent · AI Assistant</p>
</div>
</div>
<button
onClick={() => setIsOpen(false)}
className="text-white/80 hover:text-white transition-colors text-xl leading-none"
>
×
</button>
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto px-4 py-3 space-y-3 bg-gray-50">
{messages.map((msg, i) => (
<div key={i} className={`flex ${msg.role === "user" ? "justify-end" : "justify-start"}`}>
<div
className={`max-w-[80%] rounded-2xl px-4 py-2 text-sm whitespace-pre-wrap ${
msg.role === "user"
? "bg-blue-600 text-white rounded-br-sm"
: "bg-white text-gray-800 shadow-sm border border-gray-100 rounded-bl-sm"
}`}
>
{msg.content}
</div>
</div>
))}
{isLoading && (
<div className="flex justify-start">
<div className="bg-white text-gray-500 rounded-2xl rounded-bl-sm px-4 py-3 text-sm shadow-sm border border-gray-100 flex items-center gap-2">
<span className="flex gap-1">
<span className="w-1.5 h-1.5 bg-blue-400 rounded-full animate-bounce" style={{ animationDelay: "0ms" }} />
<span className="w-1.5 h-1.5 bg-blue-400 rounded-full animate-bounce" style={{ animationDelay: "150ms" }} />
<span className="w-1.5 h-1.5 bg-blue-400 rounded-full animate-bounce" style={{ animationDelay: "300ms" }} />
</span>
<span className="text-xs text-gray-400">Antwort wird generiert</span>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Input */}
<div className="px-3 py-3 bg-white border-t border-gray-100 flex gap-2 items-end">
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Ihre Frage / Your question…"
rows={1}
disabled={isLoading}
className="flex-1 resize-none rounded-xl border border-gray-200 px-3 py-2 text-sm focus:outline-none focus:border-blue-400 disabled:opacity-50 max-h-24"
/>
<button
onClick={sendMessage}
disabled={isLoading || !input.trim()}
className="bg-blue-600 hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed text-white rounded-xl px-4 py-2 text-sm font-medium transition-colors"
>
</button>
</div>
</div>
)}
{/* Floating Button */}
<button
onClick={() => setIsOpen(!isOpen)}
className="fixed bottom-6 right-6 z-50 w-14 h-14 bg-blue-600 hover:bg-blue-700 text-white rounded-full shadow-lg hover:shadow-xl transition-all flex items-center justify-center"
aria-label="Chat öffnen"
>
{isOpen ? (
<span className="text-2xl leading-none">×</span>
) : (
<svg xmlns="http://www.w3.org/2000/svg" className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" />
</svg>
)}
</button>
</>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB