This commit is contained in:
2026-03-20 19:31:54 +01:00
parent 117e794753
commit 5b383b9fe4
25 changed files with 3270 additions and 0 deletions

View File

@@ -0,0 +1,71 @@
import { useState, useEffect } from "react";
interface Props {
onSend: (text: string) => void;
disabled: boolean;
prefill?: string;
}
export function ChatInput({ onSend, disabled, prefill }: Props) {
const [value, setValue] = useState("");
useEffect(() => {
if (prefill) {
setValue(prefill);
}
}, [prefill]);
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const trimmed = value.trim();
if (!trimmed || disabled) return;
onSend(trimmed);
setValue("");
}
function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit(e);
}
}
return (
<form
onSubmit={handleSubmit}
className="flex gap-2 items-end border-t border-green-900/30 pt-4"
>
<textarea
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleKeyDown}
disabled={disabled}
placeholder="Ask about architecture, services, configs..."
rows={2}
className="
flex-1 resize-none rounded-lg px-4 py-3
bg-gray-900 border border-green-900/40
text-gray-200 placeholder-gray-600
font-mono text-sm leading-relaxed
focus:outline-none focus:border-green-700/60 focus:ring-1 focus:ring-green-700/30
disabled:opacity-40 disabled:cursor-not-allowed
transition-colors
"
/>
<button
type="submit"
disabled={disabled || !value.trim()}
className="
px-5 py-3 rounded-lg font-mono text-sm font-bold
bg-green-700 hover:bg-green-600 active:bg-green-800
text-gray-950
disabled:opacity-30 disabled:cursor-not-allowed
transition-colors
whitespace-nowrap
"
>
send
</button>
</form>
);
}

View File

@@ -0,0 +1,36 @@
import type { Message } from "../../types";
interface Props {
message: Message;
}
export function ChatMessage({ message }: Props) {
const isUser = message.role === "USER";
return (
<div
className={`flex gap-3 ${isUser ? "flex-row-reverse" : "flex-row"} mb-4`}
>
<div
className={`
flex-shrink-0 w-8 h-8 rounded-md flex items-center justify-center font-mono text-xs font-bold
${isUser ? "bg-green-900/60 text-green-300" : "bg-gray-800 text-gray-400"}
`}
>
{isUser ? "you" : "ai"}
</div>
<div
className={`
max-w-[80%] px-4 py-3 rounded-lg font-mono text-sm leading-relaxed whitespace-pre-wrap
${
isUser
? "bg-green-950/50 border border-green-800/40 text-green-100"
: "bg-gray-900 border border-gray-800/60 text-gray-200"
}
`}
>
{message.content}
</div>
</div>
);
}

View File

@@ -0,0 +1,169 @@
import { useState, useEffect, useRef, useCallback } from "react";
import type { Message } from "../../types";
import { createChat, sendMessage } from "../../services/api";
import { ChatMessage } from "./ChatMessage";
import { ChatInput } from "./ChatInput";
interface Props {
token: string | null;
isAuthLoading: boolean;
authError: string | null;
pendingQuestion: string | null;
onQuestionConsumed: () => void;
}
export function ChatSection({
token,
isAuthLoading,
authError,
pendingQuestion,
onQuestionConsumed,
}: Props) {
const [chatId, setChatId] = useState<number | null>(null);
const [messages, setMessages] = useState<Message[]>([]);
const [isSending, setIsSending] = useState(false);
const [chatError, setChatError] = useState<string | null>(null);
const [prefill, setPrefill] = useState<string | undefined>(undefined);
const bottomRef = useRef<HTMLDivElement>(null);
const initializedRef = useRef(false);
// Init chat once token is available
useEffect(() => {
if (!token || initializedRef.current) return;
initializedRef.current = true;
createChat(token)
.then((chat) => setChatId(chat.id))
.catch((err: unknown) => {
setChatError(
err instanceof Error ? err.message : "Failed to start chat"
);
});
}, [token]);
// Consume pending question from example cards
useEffect(() => {
if (pendingQuestion && chatId && !isSending) {
setPrefill(pendingQuestion);
onQuestionConsumed();
}
}, [pendingQuestion, chatId, isSending, onQuestionConsumed]);
// Auto-scroll to bottom on new messages
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
const handleSend = useCallback(
async (text: string) => {
if (!token || !chatId) return;
setPrefill(undefined);
const userMsg: Message = {
id: `user-${Date.now()}`,
content: text,
role: "USER",
createdAt: new Date().toISOString(),
};
setMessages((prev) => [...prev, userMsg]);
setIsSending(true);
setChatError(null);
try {
const response = await sendMessage(token, chatId, text);
setMessages((prev) => [
...prev,
{
id: response.id,
content: response.content,
role: "ASSISTANT",
createdAt: response.createdAt,
},
]);
} catch (err) {
setChatError(
err instanceof Error ? err.message : "Failed to get response"
);
} finally {
setIsSending(false);
}
},
[token, chatId]
);
// Trigger send when prefill is set from example questions
useEffect(() => {
if (prefill && chatId && !isSending) {
handleSend(prefill);
setPrefill(undefined);
}
}, [prefill, chatId, isSending, handleSend]);
const isInputDisabled = isAuthLoading || !token || !chatId || isSending;
return (
<section className="flex flex-col flex-1 min-h-0">
<p className="text-gray-500 font-mono text-xs uppercase tracking-widest mb-3">
// chat
</p>
<div className="flex flex-col flex-1 min-h-0 rounded-lg border border-green-900/30 bg-gray-950/40 overflow-hidden">
{/* Message list */}
<div className="flex-1 overflow-y-auto p-4">
{isAuthLoading && (
<div className="flex items-center gap-2 text-gray-500 font-mono text-sm">
<span className="inline-block w-2 h-2 rounded-full bg-green-700 animate-pulse" />
Authenticating...
</div>
)}
{authError && (
<div className="text-red-400 font-mono text-sm bg-red-950/20 border border-red-900/30 rounded px-4 py-3">
Auth error: {authError}
</div>
)}
{!isAuthLoading && token && !chatId && !chatError && (
<div className="flex items-center gap-2 text-gray-500 font-mono text-sm">
<span className="inline-block w-2 h-2 rounded-full bg-green-700 animate-pulse" />
Initializing chat session...
</div>
)}
{chatError && (
<div className="text-red-400 font-mono text-sm bg-red-950/20 border border-red-900/30 rounded px-4 py-3">
{chatError}
</div>
)}
{messages.length === 0 && chatId && !chatError && (
<div className="text-gray-600 font-mono text-sm text-center py-8">
Ask a question to get started.
</div>
)}
{messages.map((msg) => (
<ChatMessage key={msg.id} message={msg} />
))}
{isSending && (
<div className="flex gap-3 mb-4">
<div className="w-8 h-8 rounded-md flex items-center justify-center bg-gray-800 text-gray-400 font-mono text-xs font-bold flex-shrink-0">
ai
</div>
<div className="px-4 py-3 rounded-lg bg-gray-900 border border-gray-800/60 font-mono text-sm text-gray-500">
<span className="inline-flex gap-1">
<span className="animate-bounce [animation-delay:0ms]">.</span>
<span className="animate-bounce [animation-delay:150ms]">.</span>
<span className="animate-bounce [animation-delay:300ms]">.</span>
</span>
</div>
</div>
)}
<div ref={bottomRef} />
</div>
{/* Input */}
<div className="px-4 pb-4">
<ChatInput
onSend={handleSend}
disabled={isInputDisabled}
/>
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,46 @@
const QUESTIONS = [
"What port does analytics-service use?",
"How does JWT authentication work?",
"What Kafka topics are configured?",
"Describe the CI/CD pipeline",
"What databases are used?",
"How is Nginx configured?",
];
interface Props {
onSelect: (question: string) => void;
disabled: boolean;
}
export function ExampleQuestions({ onSelect, disabled }: Props) {
return (
<section className="mb-6">
<p className="text-gray-500 font-mono text-xs uppercase tracking-widest mb-3">
// example questions
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
{QUESTIONS.map((q) => (
<button
key={q}
onClick={() => onSelect(q)}
disabled={disabled}
className="
group text-left px-4 py-3 rounded-lg border border-green-900/40
bg-gray-900/60 hover:bg-green-950/40 hover:border-green-700/60
text-gray-300 hover:text-green-300
font-mono text-sm leading-snug
transition-all duration-200
disabled:opacity-40 disabled:cursor-not-allowed
hover:shadow-[0_0_12px_rgba(74,222,128,0.08)]
"
>
<span className="text-green-600 group-hover:text-green-400 mr-1.5 transition-colors">
$
</span>
{q}
</button>
))}
</div>
</section>
);
}

View File

@@ -0,0 +1,22 @@
export function HeroSection() {
return (
<section className="py-10 text-center">
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full border border-green-800/50 bg-green-950/30 mb-5">
<span className="w-1.5 h-1.5 rounded-full bg-green-400" />
<span className="text-green-400/80 font-mono text-xs tracking-widest uppercase">
Powered by RAG
</span>
</div>
<h1 className="text-4xl font-bold font-mono text-green-300 mb-3 tracking-tight">
DevOps Assistant
</h1>
<p className="text-gray-400 font-mono text-base max-w-lg mx-auto leading-relaxed">
Ask anything about the platform architecture.
<br />
<span className="text-gray-500 text-sm">
Answers based on real infrastructure docs.
</span>
</p>
</section>
);
}

View File

@@ -0,0 +1,11 @@
export function Footer() {
return (
<footer className="border-t border-green-900/30 bg-gray-950/60 mt-auto">
<div className="max-w-5xl mx-auto px-4 h-10 flex items-center justify-center">
<span className="text-gray-600 font-mono text-xs">
powered by RAG · answers based on real infrastructure docs
</span>
</div>
</footer>
);
}

View File

@@ -0,0 +1,17 @@
export function Header() {
return (
<header className="border-b border-green-900/40 bg-gray-950/80 backdrop-blur-sm sticky top-0 z-10">
<div className="max-w-5xl mx-auto px-4 h-14 flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-green-400 font-mono text-lg font-bold tracking-tight">
&gt;_ devops-assistant
</span>
</div>
<div className="flex items-center gap-1.5">
<span className="inline-block w-2 h-2 rounded-full bg-green-400 animate-pulse" />
<span className="text-green-400/70 font-mono text-xs">online</span>
</div>
</div>
</header>
);
}