negoptimAi / frontend /src /components /ChatInterface.tsx
samir12321's picture
Initial commit: Negoptim AI RAG chatbot (backend + frontend + deploy config)
af404c9
Raw
History Blame Contribute Delete
11.2 kB
"use client";
import { useEffect, useRef, useState, useMemo } from "react";
import Image from "next/image";
import { useChat } from "@/hooks/useChat";
import WelcomeSection from "./WelcomeSection";
import SuggestedQuestions from "./SuggestedQuestions";
import MessageBubble from "./MessageBubble";
import LoadingAnimation from "./LoadingAnimation";
import HexAvatar from "./HexAvatar";
import SettingsPanel from "./SettingsPanel";
export default function ChatInterface() {
const {
messages,
isLoading,
error,
sendMessage,
clearConversation,
hasMessages,
preferredModel,
setPreferredModel,
} = useChat();
const [input, setInput] = useState("");
const [settingsOpen, setSettingsOpen] = useState(false);
const [slowHint, setSlowHint] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages, isLoading]);
// Free-tier backends can cold-start (~30-60s) after idling. If a reply is
// slow, reassure the user instead of looking frozen.
useEffect(() => {
if (!isLoading) {
setSlowHint(false);
return;
}
const timer = setTimeout(() => setSlowHint(true), 8000);
return () => clearTimeout(timer);
}, [isLoading]);
const handleSubmit = async () => {
const text = input.trim();
if (!text || isLoading) return;
setInput("");
await sendMessage(text);
inputRef.current?.focus();
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
};
return (
<div className="flex flex-col h-screen bg-[#F4F6F9]">
{/* ── 3px gradient top accent bar ── */}
<div className="h-[3px] w-full flex-shrink-0 bg-gradient-to-r from-[#C7D92F] via-[#47C3A6] to-[#14B7CC]" />
{/* ── Header — dark company-brand chrome ── */}
<header className="flex-shrink-0 bg-[#0B1221] border-b border-white/10 shadow-md">
<div className="max-w-3xl mx-auto px-5 py-3 flex items-center justify-between gap-4">
{/* Left — ULiT company identity */}
<div className="flex items-center gap-4 min-w-0">
<Image
src="/images/ULiT.png"
alt="Users Love IT"
width={150}
height={55}
className="object-contain flex-shrink-0"
priority
/>
{/* Status button showing active model, remaining quota and reset timer */}
<button
onClick={() => setSettingsOpen(true)}
className="hidden sm:flex items-center gap-2.5 pl-4 border-l border-white/10 group text-left hover:bg-white/5 px-2.5 py-1.5 rounded-lg transition-colors"
title="Click to view API quota and switch model"
>
<div className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-[#8FD15E] opacity-75"></span>
<span className="relative inline-flex rounded-full h-2 w-2 bg-[#8FD15E]"></span>
</div>
<div className="flex flex-col">
<span className="text-[11px] text-slate-200 font-semibold leading-none group-hover:text-[#47C3A6] transition-colors">
{(() => {
const latestMsg = [...messages]
.reverse()
.find((m) => m.role === "assistant" && (m.modelUsed || m.rateLimit));
const currentModel = latestMsg?.modelUsed || preferredModel;
const labels: Record<string, string> = {
"llama-3.1-8b-instant": "Llama 3.1 8B",
"llama-3.3-70b-versatile": "Llama 3.3 70B",
"llama-3.1-70b-versatile": "Llama 3.1 70B",
"llama3-8b-8192": "Llama 3 8B",
};
return labels[currentModel] ?? currentModel;
})()}
</span>
{(() => {
const latestMsg = [...messages]
.reverse()
.find((m) => m.role === "assistant" && m.rateLimit);
const rl = latestMsg?.rateLimit;
if (rl) {
const reqPct = rl.remaining_requests !== null && rl.limit_requests
? Math.round((rl.remaining_requests / rl.limit_requests) * 100)
: 100;
return (
<span className="text-[9px] text-slate-400 mt-1 leading-none">
Req: {reqPct}% · ↺ {rl.reset_requests || "now"}
</span>
);
}
return (
<span className="text-[9px] text-slate-400 mt-1 leading-none">
Quota: 100% · Ready
</span>
);
})()}
</div>
</button>
</div>
{/* Right — product badge + actions */}
<div className="flex items-center gap-2.5 flex-shrink-0">
<span className="hidden sm:inline-block text-[13px] font-bold gradient-text tracking-wide mr-1">
NegOptim AI
</span>
{hasMessages && (
<>
<div className="w-px h-5 bg-white/10" />
<button
onClick={clearConversation}
className="text-[12px] text-slate-300 hover:text-white transition-colors font-medium px-3 py-1.5 rounded-lg border border-white/15 hover:border-white/30 bg-white/5 hover:bg-white/10"
>
New chat
</button>
</>
)}
{/* Settings button */}
<div className="w-px h-5 bg-white/10" />
<button
onClick={() => setSettingsOpen(true)}
className={`w-8 h-8 flex items-center justify-center rounded-lg border transition-colors ${
settingsOpen
? "border-[#47C3A6] bg-[#47C3A615] text-[#47C3A6]"
: "border-white/15 bg-white/5 text-slate-300 hover:text-white hover:border-white/30 hover:bg-white/10"
}`}
aria-label="Open settings"
title="Settings"
>
<SettingsIcon />
</button>
</div>
</div>
</header>
{/* ── Messages / Welcome area ── */}
<main className="flex-1 overflow-y-auto">
<div className="max-w-3xl mx-auto px-4 py-6">
{!hasMessages ? (
<div className="flex flex-col gap-8">
<WelcomeSection />
<SuggestedQuestions
onSelect={(q) => {
setInput(q);
inputRef.current?.focus();
}}
/>
</div>
) : (
<div className="flex flex-col gap-6">
{messages.map((msg) => (
<MessageBubble key={msg.id} message={msg} onEmailAction={sendMessage} />
))}
{isLoading && (
<div className="flex gap-3 animate-slide-up">
<div className="flex-shrink-0 mt-1">
<HexAvatar size={32} />
</div>
<div className="bg-white border border-[#E8EDF2] rounded-xl px-4 py-3.5 shadow-sm">
<LoadingAnimation />
{slowHint && (
<p className="text-[11px] text-[#94A3B8] mt-2 animate-fade-in">
Still working — the server may be waking from sleep, this can take up to a minute…
</p>
)}
</div>
</div>
)}
{error && (
<div className="bg-red-50 border border-red-100 rounded-xl px-4 py-3 text-sm text-red-500">
{error}
</div>
)}
</div>
)}
<div ref={messagesEndRef} />
</div>
</main>
{/* ── Input bar ── */}
<footer className="flex-shrink-0 bg-white border-t border-[#E8EDF2]">
<div className="max-w-3xl mx-auto px-4 py-3.5">
<div className="flex items-end gap-2.5 bg-[#F8FAFC] border border-[#E2E8F0] rounded-2xl px-4 py-3 focus-within:border-[#47C3A6] focus-within:bg-white focus-within:shadow-[0_0_0_3px_rgba(71,195,166,0.12)] transition-all duration-200">
<textarea
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Ask about ULiT, NegOptim, or commercial negotiations…"
rows={1}
maxLength={4000}
disabled={isLoading}
className="flex-1 bg-transparent text-[15px] text-[#1E293B] placeholder-[#94A3B8] resize-none focus:outline-none leading-relaxed max-h-36 disabled:opacity-50"
style={{ scrollbarWidth: "none" }}
/>
<button
onClick={handleSubmit}
disabled={!input.trim() || isLoading}
className="flex-shrink-0 w-9 h-9 rounded-xl bg-brand-gradient flex items-center justify-center disabled:opacity-30 hover:opacity-90 hover:shadow-md active:scale-95 transition-all"
aria-label="Send message"
>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path
d="M13 1L7 7M13 1L9 13L7 7M13 1L1 5L7 7"
stroke="white"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
</div>
<p className="text-center text-[10px] text-[#CBD5E1] mt-2">
Enter to send · Shift + Enter for new line
</p>
</div>
</footer>
{/* ── Settings panel ── */}
<SettingsPanel
isOpen={settingsOpen}
onClose={() => setSettingsOpen(false)}
messages={messages}
preferredModel={preferredModel}
setPreferredModel={setPreferredModel}
/>
</div>
);
}
function SettingsIcon() {
return (
<svg width="14" height="14" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z" clipRule="evenodd" />
</svg>
);
}