feat: add RAG chat widget for recruiter assistant
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 |
Reference in New Issue
Block a user