Spaces:
Paused
Paused
| import React, { useEffect, useRef, useState } from "react"; | |
| import { submitQuery } from "../api/client"; | |
| import type { QueryResponse } from "../types"; | |
| interface Props { | |
| onResponse: (r: QueryResponse) => void; | |
| onPending?: (query: string) => void; | |
| onSettled?: () => void; | |
| initialQuery?: string; | |
| vizEnabled?: boolean; | |
| onVizToggle?: (v: boolean) => void; | |
| } | |
| const EXAMPLE_QUERIES = [ | |
| "What are the key differences between BERT and GPT architectures?", | |
| "Compare LoRA vs full fine-tuning on common benchmarks", | |
| "How does RAG improve factual accuracy?", | |
| "Effect of model scale on few-shot learning?", | |
| ]; | |
| export default function QueryInterface({ | |
| onResponse, | |
| onPending, | |
| onSettled, | |
| initialQuery = "", | |
| vizEnabled = false, | |
| onVizToggle, | |
| }: Props) { | |
| const [query, setQuery] = useState(initialQuery); | |
| const [loading, setLoading] = useState(false); | |
| const [error, setError] = useState(""); | |
| const textareaRef = useRef<HTMLTextAreaElement>(null); | |
| useEffect(() => { | |
| if (initialQuery) setQuery(initialQuery); | |
| }, [initialQuery]); | |
| // Auto-grow textarea | |
| useEffect(() => { | |
| const el = textareaRef.current; | |
| if (!el) return; | |
| el.style.height = "auto"; | |
| el.style.height = `${Math.min(el.scrollHeight, 200)}px`; | |
| }, [query]); | |
| async function handleSubmit(e: React.FormEvent) { | |
| e.preventDefault(); | |
| const trimmed = query.trim(); | |
| if (!trimmed) return; | |
| setError(""); | |
| setLoading(true); | |
| onPending?.(trimmed); | |
| try { | |
| const result = await submitQuery(trimmed, vizEnabled); | |
| setQuery(""); | |
| onResponse(result); | |
| } catch (err: unknown) { | |
| const msg = | |
| (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail ?? | |
| "Query failed — please try again."; | |
| setError(String(msg)); | |
| onSettled?.(); | |
| } finally { | |
| setLoading(false); | |
| } | |
| } | |
| return ( | |
| <div className="space-y-3"> | |
| <form onSubmit={handleSubmit}> | |
| {/* Textarea */} | |
| <textarea | |
| ref={textareaRef} | |
| rows={2} | |
| value={query} | |
| onChange={(e) => setQuery(e.target.value)} | |
| onKeyDown={(e) => { | |
| if (e.key === "Enter" && !e.shiftKey) { | |
| e.preventDefault(); | |
| handleSubmit(e as unknown as React.FormEvent); | |
| } | |
| }} | |
| disabled={loading} | |
| placeholder="Ask a question about AI / ML research papers…" | |
| aria-label="Research query" | |
| className="w-full border border-slate-200 rounded-xl px-4 py-3 text-sm resize-none focus:outline-none focus:ring-2 focus:ring-brand-400 focus:border-transparent placeholder:text-slate-400 disabled:opacity-60 bg-slate-50 focus:bg-white transition-colors leading-relaxed" | |
| /> | |
| {/* Controls row */} | |
| <div className="flex items-center gap-2 mt-2.5 flex-wrap"> | |
| {/* Example chips */} | |
| <div className="flex items-center gap-1.5 flex-1 min-w-0 overflow-x-auto pb-0.5 hide-scrollbar"> | |
| {EXAMPLE_QUERIES.map((q) => ( | |
| <button | |
| key={q} | |
| type="button" | |
| onClick={() => { | |
| setQuery(q); | |
| textareaRef.current?.focus(); | |
| }} | |
| disabled={loading} | |
| className="shrink-0 text-xs bg-slate-100 hover:bg-brand-50 hover:text-brand-700 text-slate-500 border border-transparent hover:border-brand-200 px-2.5 py-1 rounded-lg transition-colors disabled:opacity-50 whitespace-nowrap" | |
| > | |
| {q.length > 38 ? q.slice(0, 38) + "…" : q} | |
| </button> | |
| ))} | |
| </div> | |
| {/* Viz toggle */} | |
| {onVizToggle && ( | |
| <button | |
| type="button" | |
| onClick={() => onVizToggle(!vizEnabled)} | |
| disabled={loading} | |
| aria-pressed={vizEnabled} | |
| title={vizEnabled ? "Disable visualization" : "Enable visualization"} | |
| className={`shrink-0 flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-lg border transition-colors disabled:opacity-50 font-medium ${ | |
| vizEnabled | |
| ? "bg-violet-50 border-violet-200 text-violet-700" | |
| : "bg-slate-50 border-slate-200 text-slate-500 hover:border-slate-300 hover:text-slate-700" | |
| }`} | |
| > | |
| <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> | |
| <path strokeLinecap="round" strokeLinejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 0 1 3 19.875v-6.75ZM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V8.625ZM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V4.125Z" /> | |
| </svg> | |
| Viz | |
| {/* Dot indicator */} | |
| <span className={`w-1.5 h-1.5 rounded-full transition-colors ${vizEnabled ? "bg-violet-500" : "bg-slate-300"}`} /> | |
| </button> | |
| )} | |
| {/* Submit */} | |
| <button | |
| type="submit" | |
| disabled={loading || !query.trim()} | |
| aria-label="Submit query" | |
| className="shrink-0 bg-brand-600 hover:bg-brand-700 active:bg-brand-800 text-white text-sm font-medium px-4 py-1.5 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2 shadow-sm" | |
| > | |
| {loading ? ( | |
| <> | |
| <svg className="animate-spin h-3.5 w-3.5" viewBox="0 0 24 24" fill="none"> | |
| <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" /> | |
| <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" /> | |
| </svg> | |
| Thinking… | |
| </> | |
| ) : ( | |
| <> | |
| Ask | |
| <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}> | |
| <path strokeLinecap="round" strokeLinejoin="round" d="M13.5 4.5 21 12m0 0-7.5 7.5M21 12H3" /> | |
| </svg> | |
| </> | |
| )} | |
| </button> | |
| </div> | |
| </form> | |
| {/* Error banner */} | |
| {error && ( | |
| <div className="flex items-start gap-2 text-red-600 text-sm bg-red-50 border border-red-100 rounded-xl px-3 py-2.5" role="alert"> | |
| <svg className="w-4 h-4 shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> | |
| <path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" /> | |
| </svg> | |
| <span>{error}</span> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } | |