Spaces:
Sleeping
Sleeping
| "use client"; | |
| import { useEffect, useMemo, useRef, useState } from "react"; | |
| import { Send, Settings, RefreshCw, Filter, Bug, Link as LinkIcon, Search, SlidersHorizontal } from "lucide-react"; | |
| import { fetchChat, fetchRecommend } from "@/lib/api"; | |
| import { useLocalStorage } from "@/lib/useLocalStorage"; | |
| import type { Assessment, ChatResponse, DebugPayload } from "@/types"; | |
| type HistoryItem = { | |
| id: string; | |
| query: string; | |
| response: ChatResponse | null; | |
| error?: string; | |
| ts: number; | |
| }; | |
| const SAMPLE_PROMPTS = [ | |
| "Java dev + collaboration + 40 minutes", | |
| "Sales graduate assessment for 60 minutes", | |
| "Culture fit assessment for COO, 60 minutes", | |
| ]; | |
| type Mode = "recommend" | "chat"; | |
| export default function Home() { | |
| const [apiBase, setApiBase] = useLocalStorage("api_base", "http://localhost:8000"); | |
| const [mode, setMode] = useLocalStorage<Mode>("mode", "recommend"); | |
| const [verbose, setVerbose] = useLocalStorage("verbose", false); | |
| const [llmModel, setLlmModel] = useLocalStorage("llm_model", "Qwen/Qwen2.5-1.5B-Instruct"); | |
| const [query, setQuery] = useState(""); | |
| const [clarification, setClarification] = useState(""); | |
| const [history, setHistory] = useState<HistoryItem[]>([]); | |
| const [loading, setLoading] = useState(false); | |
| const [activeIndex, setActiveIndex] = useState<number | null>(null); | |
| const [filters, setFilters] = useState({ | |
| search: "", | |
| remote: "any" as "any" | "Yes" | "No", | |
| adaptive: "any" as "any" | "Yes" | "No", | |
| duration: "any" as "any" | "<=20" | "<=40" | "<=60" | "unknown", | |
| sort: "match" as "match" | "short" | "adaptive", | |
| }); | |
| const abortRef = useRef<AbortController | null>(null); | |
| useEffect(() => { | |
| if (history.length && activeIndex === null) { | |
| setActiveIndex(history.length - 1); | |
| } | |
| }, [history, activeIndex]); | |
| const activeItem = activeIndex !== null ? history[activeIndex] : null; | |
| const activeResults = (activeItem?.response?.recommended_assessments || | |
| activeItem?.response?.final_results || | |
| []) as Assessment[]; | |
| const debug = activeItem?.response?.debug as DebugPayload | undefined; | |
| const filteredResults = useMemo(() => { | |
| let res = [...activeResults]; | |
| const { search, remote, adaptive, duration, sort } = filters; | |
| if (search.trim()) { | |
| const q = search.toLowerCase(); | |
| res = res.filter( | |
| (r) => | |
| r.name?.toLowerCase().includes(q) || | |
| r.description?.toLowerCase().includes(q) || | |
| r.test_type?.some((t) => t.toLowerCase().includes(q)) | |
| ); | |
| } | |
| if (remote !== "any") { | |
| res = res.filter((r) => (r.remote_support || "").toLowerCase() === remote.toLowerCase()); | |
| } | |
| if (adaptive !== "any") { | |
| res = res.filter((r) => (r.adaptive_support || "").toLowerCase() === adaptive.toLowerCase()); | |
| } | |
| if (duration !== "any") { | |
| res = res.filter((r) => { | |
| const d = r.duration; | |
| if (d === null || d === undefined) return duration === "unknown"; | |
| if (duration === "<=20") return d <= 20; | |
| if (duration === "<=40") return d <= 40; | |
| if (duration === "<=60") return d <= 60; | |
| return true; | |
| }); | |
| } | |
| if (sort === "short") { | |
| res.sort((a, b) => (a.duration || 999) - (b.duration || 999)); | |
| } else if (sort === "adaptive") { | |
| res.sort((a, b) => (b.adaptive_support === "Yes" ? 1 : 0) - (a.adaptive_support === "Yes" ? 1 : 0)); | |
| } | |
| return res; | |
| }, [activeResults, filters]); | |
| const send = async () => { | |
| if (!query.trim()) return; | |
| setLoading(true); | |
| abortRef.current?.abort(); | |
| const controller = new AbortController(); | |
| abortRef.current = controller; | |
| const body: any = { query, verbose }; | |
| if (clarification.trim()) body.clarification_answer = clarification.trim(); | |
| if (mode === "recommend" && llmModel) body.llm_model = llmModel; | |
| const id = crypto.randomUUID(); | |
| const ts = Date.now(); | |
| setHistory((h) => [...h, { id, query, response: null, ts }]); | |
| try { | |
| const res = | |
| mode === "chat" | |
| ? await fetchChat(apiBase, body, controller.signal) | |
| : await fetchRecommend(apiBase, body, controller.signal); | |
| setHistory((h) => | |
| h.map((item) => (item.id === id ? { ...item, response: res, error: undefined } : item)) | |
| ); | |
| setActiveIndex(history.length); // new item index | |
| setQuery(""); | |
| setClarification(""); | |
| } catch (err: any) { | |
| setHistory((h) => h.map((item) => (item.id === id ? { ...item, error: err.message } : item))); | |
| } finally { | |
| setLoading(false); | |
| } | |
| }; | |
| const header = ( | |
| <div className="flex items-center justify-between mb-3"> | |
| <div> | |
| <h1 className="text-3xl font-semibold text-slate-900">SHL Assessment Recommender</h1> | |
| <p className="text-sm text-slate-600">Chat to get top-10 assessments. Filters and debug on the right.</p> | |
| </div> | |
| <div className="hidden md:flex items-center gap-2 text-xs text-slate-500"> | |
| <RefreshCw size={16} /> Live against FastAPI backend | |
| </div> | |
| </div> | |
| ); | |
| const settings = ( | |
| <div className="flex flex-wrap gap-3 text-sm"> | |
| <div className="flex items-center gap-2"> | |
| <label className="font-medium">Mode</label> | |
| <select | |
| className="border rounded px-2 py-1" | |
| value={mode} | |
| onChange={(e) => setMode(e.target.value as Mode)} | |
| > | |
| <option value="recommend">/recommend</option> | |
| <option value="chat">/chat</option> | |
| </select> | |
| </div> | |
| <div className="flex items-center gap-2"> | |
| <label className="font-medium">LLM</label> | |
| <input | |
| className="border rounded px-2 py-1" | |
| value={llmModel} | |
| onChange={(e) => setLlmModel(e.target.value)} | |
| placeholder="Qwen/Qwen2.5-1.5B-Instruct" | |
| /> | |
| </div> | |
| <label className="flex items-center gap-2"> | |
| <input type="checkbox" checked={verbose} onChange={(e) => setVerbose(e.target.checked)} /> | |
| Verbose debug | |
| </label> | |
| </div> | |
| ); | |
| const chatPanel = ( | |
| <div className="flex flex-col h-full"> | |
| <div className="flex flex-col gap-3 flex-1 overflow-hidden bg-white border rounded-xl shadow-sm p-4"> | |
| <div className="flex items-center justify-between"> | |
| <div className="text-lg font-semibold flex items-center gap-2"> | |
| <Send size={18} /> Chat | |
| </div> | |
| <button | |
| onClick={() => { | |
| setQuery(SAMPLE_PROMPTS[0]); | |
| }} | |
| className="text-xs text-blue-600 hover:underline" | |
| > | |
| Use sample | |
| </button> | |
| </div> | |
| <div className="flex gap-2 items-center text-sm"> | |
| <label className="font-medium min-w-[70px]">API base</label> | |
| <input | |
| className="border rounded px-2 py-1 w-full" | |
| value={apiBase} | |
| onChange={(e) => setApiBase(e.target.value)} | |
| /> | |
| </div> | |
| <textarea | |
| className="border rounded-lg p-3 w-full text-sm min-h-[140px] resize-none focus:ring-2 focus:ring-blue-200" | |
| placeholder="Enter job description or query" | |
| value={query} | |
| onChange={(e) => setQuery(e.target.value)} | |
| onKeyDown={(e) => { | |
| if (e.key === "Enter" && !e.shiftKey) { | |
| e.preventDefault(); | |
| send(); | |
| } | |
| }} | |
| /> | |
| <div className="flex gap-2"> | |
| {SAMPLE_PROMPTS.map((p) => ( | |
| <button | |
| key={p} | |
| onClick={() => setQuery(p)} | |
| className="text-xs bg-slate-100 hover:bg-slate-200 px-2 py-1 rounded" | |
| > | |
| {p} | |
| </button> | |
| ))} | |
| </div> | |
| <div className="flex gap-3 items-center"> | |
| <input | |
| className="border rounded px-2 py-1 text-sm flex-1" | |
| placeholder="Clarification (if asked)" | |
| value={clarification} | |
| onChange={(e) => setClarification(e.target.value)} | |
| /> | |
| <button | |
| onClick={send} | |
| disabled={loading} | |
| className="bg-blue-600 text-white px-4 py-2 rounded-lg flex items-center gap-2 hover:bg-blue-700 disabled:opacity-60" | |
| > | |
| <Send size={16} /> {loading ? "Sending..." : "Send"} | |
| </button> | |
| <button | |
| onClick={() => setVerbose(!verbose)} | |
| className="p-2 border rounded-lg hover:bg-slate-100" | |
| title="Toggle verbose debug" | |
| > | |
| <Bug size={16} /> | |
| </button> | |
| <button | |
| onClick={() => setMode(mode === "recommend" ? "chat" : "recommend")} | |
| className="p-2 border rounded-lg hover:bg-slate-100" | |
| title="Toggle endpoint" | |
| > | |
| <Settings size={16} /> | |
| </button> | |
| </div> | |
| {settings} | |
| </div> | |
| <div className="mt-3 bg-white border rounded-xl shadow-sm p-3 text-sm text-slate-600 max-h-48 overflow-auto"> | |
| <div className="font-semibold mb-2">History</div> | |
| {history.length === 0 && <div className="text-slate-400">No queries yet.</div>} | |
| {history.map((h, idx) => ( | |
| <button | |
| key={h.id} | |
| onClick={() => setActiveIndex(idx)} | |
| className={`block w-full text-left px-2 py-1 rounded ${ | |
| idx === activeIndex ? "bg-blue-50 text-blue-700" : "hover:bg-slate-100" | |
| }`} | |
| > | |
| <div className="font-medium text-sm truncate">{h.query}</div> | |
| <div className="text-xs text-slate-500">{new Date(h.ts).toLocaleTimeString()}</div> | |
| {h.error && <div className="text-xs text-red-600">Error: {h.error}</div>} | |
| </button> | |
| ))} | |
| </div> | |
| </div> | |
| ); | |
| const resultsPanel = ( | |
| <div className="flex flex-col h-full"> | |
| <div className="bg-white border rounded-xl shadow-sm p-4 flex flex-col gap-3"> | |
| <div className="flex items-center justify-between"> | |
| <div className="text-lg font-semibold flex items-center gap-2"> | |
| <Filter size={18} /> Results | |
| </div> | |
| <div className="flex items-center gap-2"> | |
| <div className="relative"> | |
| <Search className="absolute left-2 top-2.5 h-4 w-4 text-slate-400" /> | |
| <input | |
| className="pl-8 pr-3 py-2 border rounded-lg text-sm" | |
| placeholder="Search results" | |
| value={filters.search} | |
| onChange={(e) => setFilters((f) => ({ ...f, search: e.target.value }))} | |
| /> | |
| </div> | |
| <SlidersHorizontal size={16} className="text-slate-500" /> | |
| </div> | |
| </div> | |
| <div className="flex flex-wrap gap-3 text-xs"> | |
| <select | |
| className="border rounded px-2 py-1" | |
| value={filters.remote} | |
| onChange={(e) => setFilters((f) => ({ ...f, remote: e.target.value as any }))} | |
| > | |
| <option value="any">Remote: Any</option> | |
| <option value="Yes">Remote: Yes</option> | |
| <option value="No">Remote: No</option> | |
| </select> | |
| <select | |
| className="border rounded px-2 py-1" | |
| value={filters.adaptive} | |
| onChange={(e) => setFilters((f) => ({ ...f, adaptive: e.target.value as any }))} | |
| > | |
| <option value="any">Adaptive: Any</option> | |
| <option value="Yes">Adaptive: Yes</option> | |
| <option value="No">Adaptive: No</option> | |
| </select> | |
| <select | |
| className="border rounded px-2 py-1" | |
| value={filters.duration} | |
| onChange={(e) => setFilters((f) => ({ ...f, duration: e.target.value as any }))} | |
| > | |
| <option value="any">Duration: Any</option> | |
| <option value="<=20">≤ 20 min</option> | |
| <option value="<=40">≤ 40 min</option> | |
| <option value="<=60">≤ 60 min</option> | |
| <option value="unknown">Unknown only</option> | |
| </select> | |
| <select | |
| className="border rounded px-2 py-1" | |
| value={filters.sort} | |
| onChange={(e) => setFilters((f) => ({ ...f, sort: e.target.value as any }))} | |
| > | |
| <option value="match">Sort: Best match</option> | |
| <option value="short">Sort: Shortest</option> | |
| <option value="adaptive">Sort: Adaptive first</option> | |
| </select> | |
| </div> | |
| <div className="grid md:grid-cols-2 lg:grid-cols-2 gap-3"> | |
| {filteredResults.length === 0 && ( | |
| <div className="text-sm text-slate-500">No results yet. Submit a query to see recommendations.</div> | |
| )} | |
| {filteredResults.map((r, idx) => ( | |
| <div key={idx} className="border rounded-xl p-4 shadow-sm hover:shadow-md transition bg-slate-50"> | |
| <div className="flex items-start justify-between gap-2"> | |
| <a | |
| href={r.url} | |
| target="_blank" | |
| rel="noreferrer" | |
| className="font-semibold text-slate-900 hover:text-blue-600" | |
| > | |
| {r.name || "Untitled"} | |
| </a> | |
| <button | |
| className="text-slate-500 hover:text-blue-600" | |
| onClick={() => r.url && navigator.clipboard.writeText(r.url)} | |
| > | |
| <LinkIcon size={16} /> | |
| </button> | |
| </div> | |
| <div className="flex flex-wrap gap-2 mt-2"> | |
| {r.test_type?.map((t) => ( | |
| <span key={t} className="text-[11px] bg-blue-50 text-blue-700 px-2 py-1 rounded-full border border-blue-100"> | |
| {t} | |
| </span> | |
| ))} | |
| <span className="text-[11px] bg-slate-100 text-slate-700 px-2 py-1 rounded-full border border-slate-200"> | |
| {r.duration ? `${r.duration} min` : "Duration unknown"} | |
| </span> | |
| <span className="text-[11px] bg-emerald-50 text-emerald-700 px-2 py-1 rounded-full border border-emerald-100"> | |
| Remote: {r.remote_support || "?"} | |
| </span> | |
| <span className="text-[11px] bg-indigo-50 text-indigo-700 px-2 py-1 rounded-full border border-indigo-100"> | |
| Adaptive: {r.adaptive_support || "?"} | |
| </span> | |
| </div> | |
| <p className="text-sm text-slate-700 mt-2 overflow-hidden text-ellipsis">{r.description || "No description."}</p> | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| {verbose && debug && ( | |
| <div className="mt-3 bg-white border rounded-xl shadow-sm p-4"> | |
| <div className="flex items-center gap-2 text-sm font-semibold mb-2"> | |
| <Bug size={16} /> Debug | |
| </div> | |
| <div className="grid md:grid-cols-2 gap-3 text-xs"> | |
| <div className="bg-slate-50 border rounded p-2"> | |
| <div className="font-semibold mb-1">Plan</div> | |
| <pre className="overflow-auto max-h-48 text-slate-700">{JSON.stringify(debug.plan, null, 2)}</pre> | |
| </div> | |
| {debug.fusion && ( | |
| <div className="bg-slate-50 border rounded p-2"> | |
| <div className="font-semibold mb-1">Fusion</div> | |
| <pre className="overflow-auto max-h-48 text-slate-700">{JSON.stringify(debug.fusion, null, 2)}</pre> | |
| </div> | |
| )} | |
| {debug.candidates && ( | |
| <div className="bg-slate-50 border rounded p-2 col-span-2"> | |
| <div className="font-semibold mb-1">Top candidates</div> | |
| <pre className="overflow-auto max-h-60 text-slate-700">{JSON.stringify(debug.candidates, null, 2)}</pre> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| return ( | |
| <main className="min-h-screen bg-slate-100"> | |
| <div className="app-shell py-6"> | |
| {header} | |
| <div className="grid lg:grid-cols-2 gap-6 mt-4"> | |
| {chatPanel} | |
| {resultsPanel} | |
| </div> | |
| </div> | |
| </main> | |
| ); | |
| } | |