Spaces:
Running
Running
| "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> | |
| ); | |
| } | |