Spaces:
Sleeping
Sleeping
| import { useState, useRef, useEffect, useCallback } from 'react' | |
| import { motion, AnimatePresence } from 'framer-motion' | |
| import { | |
| Send, CheckCircle2, XCircle, ChevronDown, ChevronUp, | |
| Loader2, MessageSquare, Zap, RefreshCw, Trash2, | |
| } from 'lucide-react' | |
| import { useStore } from '../store/useStore' | |
| import { streamExecuteQuery, submitFeedback, fetchPromptHistory } from '../lib/api' | |
| import { ResultsTable } from './ResultsTable' | |
| import type { ChatMessage, AttemptStep } from '../lib/types' | |
| // ─── SQL Syntax Highlighter ─────────────────────────────────────── | |
| const SQL_KEYWORDS = /\b(SELECT|FROM|WHERE|JOIN|LEFT|RIGHT|INNER|OUTER|FULL|ON|GROUP\s+BY|ORDER\s+BY|HAVING|LIMIT|OFFSET|UNION|ALL|DISTINCT|AS|AND|OR|NOT|IN|IS|NULL|LIKE|BETWEEN|CASE|WHEN|THEN|ELSE|END|WITH|CTE|INSERT|UPDATE|DELETE|CREATE|DROP|ALTER|TABLE|INDEX|VIEW|SET|VALUES|INTO|EXISTS|COUNT|SUM|AVG|MIN|MAX|COALESCE|NULLIF|CAST|OVER|PARTITION\s+BY|ROW_NUMBER|RANK|DENSE_RANK|LAG|LEAD|DATE|STRFTIME|JULIANDAY|ROUND|ABS|LENGTH|SUBSTR|UPPER|LOWER|TRIM|REPLACE|IFNULL)\b/gi | |
| function SqlBlock({ sql, streaming }: { sql: string; streaming?: boolean }) { | |
| const parts: React.ReactNode[] = [] | |
| let last = 0 | |
| let match: RegExpExecArray | null | |
| const re = new RegExp(SQL_KEYWORDS.source, 'gi') | |
| while ((match = re.exec(sql)) !== null) { | |
| if (match.index > last) { | |
| parts.push(<span key={`t-${last}`}>{sql.slice(last, match.index)}</span>) | |
| } | |
| parts.push( | |
| <span key={`k-${match.index}`} className="sql-keyword"> | |
| {match[0]} | |
| </span> | |
| ) | |
| last = match.index + match[0].length | |
| } | |
| if (last < sql.length) { | |
| parts.push(<span key={`t-end`}>{sql.slice(last)}</span>) | |
| } | |
| return ( | |
| <pre | |
| className="px-3 py-2.5 text-xs font-mono bg-violet-950/20 whitespace-pre-wrap overflow-x-auto leading-relaxed border-t border-white/[0.04]" | |
| style={{ color: 'rgba(221, 214, 254, 0.8)' }} | |
| > | |
| {parts} | |
| {streaming && <span className="cursor-blink" />} | |
| </pre> | |
| ) | |
| } | |
| // ─── Attempt badge ──────────────────────────────────────────────── | |
| function AttemptBadge({ attempt, total }: { attempt: number; total: number }) { | |
| const colors = | |
| attempt === 1 | |
| ? 'text-gray-400 bg-white/5 border-white/10' | |
| : attempt === 2 | |
| ? 'text-amber-400 bg-amber-500/10 border-amber-500/20' | |
| : attempt === 3 | |
| ? 'text-orange-400 bg-orange-500/10 border-orange-500/20' | |
| : 'text-red-400 bg-red-500/10 border-red-500/20' | |
| return ( | |
| <span className={`text-[10px] font-semibold px-2 py-0.5 rounded-full border ${colors}`}> | |
| Attempt {attempt}/{total} | |
| </span> | |
| ) | |
| } | |
| // ─── RL Action badge ────────────────────────────────────────────── | |
| function RLActionBadge({ action, score }: { action: string; score?: number }) { | |
| return ( | |
| <span className="inline-flex items-center gap-1 text-[10px] font-semibold px-2 py-0.5 rounded-full border border-orange-500/30 bg-orange-500/10 text-orange-400"> | |
| <Zap size={9} /> | |
| {action} | |
| {score !== undefined && ( | |
| <span className="text-orange-400/60 ml-0.5">{score.toFixed(2)}</span> | |
| )} | |
| </span> | |
| ) | |
| } | |
| // ─── Reward display ────────────────────────────────────────────── | |
| function RewardBadge({ reward }: { reward: number }) { | |
| const positive = reward >= 0 | |
| return ( | |
| <motion.span | |
| initial={{ scale: 0.8, opacity: 0 }} | |
| animate={{ scale: 1, opacity: 1 }} | |
| transition={{ type: 'spring', stiffness: 300 }} | |
| className={`inline-flex items-center gap-0.5 text-[11px] font-bold tabular-nums reward-pulse ${ | |
| positive ? 'text-green-400' : 'text-red-400' | |
| }`} | |
| > | |
| {positive ? '+' : ''}{reward.toFixed(2)} | |
| </motion.span> | |
| ) | |
| } | |
| // ─── Attempt steps collapsible ──────────────────────────────────── | |
| function AttemptSteps({ steps }: { steps: AttemptStep[] }) { | |
| const [open, setOpen] = useState(false) | |
| if (steps.length <= 1) return null | |
| return ( | |
| <div className="border border-white/[0.05] rounded-xl overflow-hidden"> | |
| <button | |
| onClick={() => setOpen((v) => !v)} | |
| className="w-full flex items-center justify-between px-3 py-2 bg-white/[0.02] hover:bg-white/[0.04] transition-colors text-[10px] text-gray-500" | |
| > | |
| <span>{steps.length} attempts to solve</span> | |
| {open ? <ChevronUp size={11} /> : <ChevronDown size={11} />} | |
| </button> | |
| <AnimatePresence> | |
| {open && ( | |
| <motion.div | |
| initial={{ height: 0, opacity: 0 }} | |
| animate={{ height: 'auto', opacity: 1 }} | |
| exit={{ height: 0, opacity: 0 }} | |
| transition={{ duration: 0.15 }} | |
| className="overflow-hidden" | |
| > | |
| <div className="flex flex-col divide-y divide-white/[0.04]"> | |
| {steps.map((step) => ( | |
| <div key={step.attempt} className="px-3 py-2"> | |
| <div className="flex items-center gap-2 mb-1.5"> | |
| <AttemptBadge attempt={step.attempt} total={steps.length} /> | |
| {step.action && ( | |
| <RLActionBadge action={step.action} score={step.actionScore} /> | |
| )} | |
| {step.reward !== undefined && <RewardBadge reward={step.reward} />} | |
| </div> | |
| {step.error && ( | |
| <div className="text-[10px] text-red-400/70 mb-1 bg-red-500/5 rounded px-2 py-1 border border-red-500/15"> | |
| {step.error} | |
| </div> | |
| )} | |
| <SqlBlock sql={step.sql} /> | |
| </div> | |
| ))} | |
| </div> | |
| </motion.div> | |
| )} | |
| </AnimatePresence> | |
| </div> | |
| ) | |
| } | |
| // ─── Suggested query chips ──────────────────────────────────────── | |
| const SUGGESTED: Record<string, string[]> = { | |
| easy: ['Show all products', 'List users from USA', 'What categories exist?'], | |
| medium: ['Top 5 sellers by revenue', 'Average order value by country', 'Products with low stock'], | |
| hard: ['Rolling 7-day revenue', 'Seller ranking with rank change', 'Cohort retention analysis'], | |
| } | |
| function SuggestionSkeleton() { | |
| return ( | |
| <div className="flex flex-col gap-2 w-full max-w-sm"> | |
| {[90, 110, 80].map((w, i) => ( | |
| <div | |
| key={i} | |
| className="flex items-center gap-2 px-3 py-2.5 rounded-xl border border-white/[0.06] bg-white/[0.02]" | |
| > | |
| <div className="w-1 h-3 rounded bg-violet-500/20 animate-pulse shrink-0" /> | |
| <div | |
| className="h-3 rounded bg-white/8 animate-pulse" | |
| style={{ width: w }} | |
| /> | |
| </div> | |
| ))} | |
| </div> | |
| ) | |
| } | |
| function EmptyState({ onSelect }: { onSelect: (q: string) => void }) { | |
| const { taskDifficulty, isCustomDb, customDbSuggestions, suggestionsLoading } = useStore() | |
| const suggestions = isCustomDb ? customDbSuggestions : (SUGGESTED[taskDifficulty] ?? SUGGESTED.easy) | |
| return ( | |
| <div className="flex flex-col items-center justify-center h-full gap-6 px-8 text-center"> | |
| <div> | |
| <div | |
| className="w-12 h-12 rounded-2xl flex items-center justify-center mx-auto mb-4" | |
| style={{ background: '#1e3a5f', boxShadow: '0 8px 24px rgba(30,58,95,0.4)' }} | |
| > | |
| <MessageSquare size={22} className="text-white" /> | |
| </div> | |
| <h2 className="text-base font-semibold text-white mb-1">Ask about your data</h2> | |
| <p className="text-xs text-gray-500 max-w-xs"> | |
| Type a question in natural language. The agent will generate SQL, execute it, | |
| and self-repair on errors using reinforcement learning. | |
| </p> | |
| </div> | |
| <div className="flex flex-col gap-2 w-full max-w-sm"> | |
| <div className="text-[10px] text-gray-600 uppercase tracking-wider mb-0.5"> | |
| {isCustomDb && suggestionsLoading ? 'Generating suggestions…' : 'Try these queries'} | |
| </div> | |
| {isCustomDb && suggestionsLoading ? ( | |
| <SuggestionSkeleton /> | |
| ) : suggestions.length > 0 ? ( | |
| suggestions.map((q) => ( | |
| <button | |
| key={q} | |
| onClick={() => onSelect(q)} | |
| className="flex items-center gap-2 px-3 py-2.5 rounded-xl border border-white/[0.06] bg-white/[0.02] hover:bg-white/[0.05] hover:border-violet-500/30 transition-all text-left group" | |
| > | |
| <span className="text-violet-500 shrink-0 group-hover:text-violet-400">›</span> | |
| <span className="text-xs text-gray-300">{q}</span> | |
| </button> | |
| )) | |
| ) : null} | |
| </div> | |
| </div> | |
| ) | |
| } | |
| // ─── Message Card ───────────────────────────────────────────────── | |
| function MessageCard({ | |
| msg, | |
| onFeedback, | |
| onRetry, | |
| }: { | |
| msg: ChatMessage | |
| onFeedback: (id: string, correct: boolean, remark?: string) => Promise<void> | |
| onRetry: (q: string, previousSql?: string) => void | |
| }) { | |
| const [sqlOpen, setSqlOpen] = useState(true) | |
| const [wrongOpen, setWrongOpen] = useState(false) | |
| const [remark, setRemark] = useState('') | |
| return ( | |
| <div className="flex flex-col gap-2.5"> | |
| {/* User question bubble */} | |
| <div className="flex justify-end"> | |
| <div className="max-w-[80%] bg-violet-600/20 border border-violet-500/25 rounded-2xl rounded-tr-sm px-4 py-2.5"> | |
| <p className="text-sm text-white leading-relaxed">{msg.question}</p> | |
| </div> | |
| </div> | |
| {/* Agent response */} | |
| <div className="flex flex-col gap-2"> | |
| {/* Streaming thinking */} | |
| {msg.status === 'streaming' && !msg.sql && ( | |
| <div className="flex items-center gap-2 text-xs text-gray-500 px-1"> | |
| <Loader2 size={11} className="animate-spin text-violet-400" /> | |
| Generating SQL... | |
| </div> | |
| )} | |
| {/* Multiple attempts */} | |
| <AttemptSteps steps={msg.steps} /> | |
| {/* Final SQL block */} | |
| {msg.sql && ( | |
| <div className="border border-white/[0.06] rounded-xl overflow-hidden"> | |
| <button | |
| onClick={() => setSqlOpen((v) => !v)} | |
| className="w-full flex items-center justify-between px-3 py-2 bg-white/[0.02] hover:bg-white/[0.04] transition-colors" | |
| > | |
| <div className="flex items-center gap-2"> | |
| <span className="text-[10px] font-semibold text-gray-500 uppercase tracking-wider"> | |
| SQL | |
| </span> | |
| {msg.status === 'streaming' && ( | |
| <Loader2 size={10} className="animate-spin text-violet-400" /> | |
| )} | |
| {msg.attempts > 1 && ( | |
| <AttemptBadge attempt={msg.attempts} total={msg.attempts} /> | |
| )} | |
| </div> | |
| {sqlOpen ? ( | |
| <ChevronUp size={11} className="text-gray-600" /> | |
| ) : ( | |
| <ChevronDown size={11} className="text-gray-600" /> | |
| )} | |
| </button> | |
| {sqlOpen && ( | |
| <SqlBlock sql={msg.sql} streaming={msg.status === 'streaming'} /> | |
| )} | |
| </div> | |
| )} | |
| {/* Executing indicator */} | |
| {msg.status === 'streaming' && msg.sql && msg.rows.length === 0 && !msg.errorMsg && ( | |
| <div className="flex items-center gap-2 text-xs text-gray-500 px-1"> | |
| <Loader2 size={11} className="animate-spin text-violet-400" /> | |
| Executing... | |
| </div> | |
| )} | |
| {/* RL badges row */} | |
| {(msg.rlAction || msg.reward !== undefined) && ( | |
| <div className="flex items-center gap-2 flex-wrap"> | |
| {msg.rlAction && ( | |
| <RLActionBadge action={msg.rlAction} score={msg.rlActionScore} /> | |
| )} | |
| {msg.reward !== undefined && <RewardBadge reward={msg.reward} />} | |
| </div> | |
| )} | |
| {/* Result table */} | |
| {msg.status === 'done' && msg.attempts > 0 && ( | |
| <div className="flex flex-col gap-1.5"> | |
| <div className="flex items-center gap-2 text-[10px] px-0.5"> | |
| <CheckCircle2 size={11} className="text-green-400" /> | |
| <span className="text-green-400 font-semibold">Success</span> | |
| <span className="text-gray-600"> | |
| · {msg.rowCount} row{msg.rowCount !== 1 ? 's' : ''} | |
| </span> | |
| {msg.attempts > 1 && ( | |
| <span className="text-amber-400/60">{msg.attempts} attempts</span> | |
| )} | |
| </div> | |
| <ResultsTable rows={msg.rows} rowCount={msg.rowCount} /> | |
| </div> | |
| )} | |
| {/* Error */} | |
| {msg.status === 'error' && ( | |
| <div className="flex items-start gap-2 bg-red-500/10 border border-red-500/20 rounded-xl px-3 py-2.5 text-xs text-red-300"> | |
| <XCircle size={12} className="shrink-0 mt-0.5" /> | |
| <div> | |
| <p className="font-semibold mb-0.5">Query failed</p> | |
| <p className="opacity-80">{msg.errorMsg ?? 'Agent exhausted all repair attempts'}</p> | |
| </div> | |
| </div> | |
| )} | |
| {/* Feedback */} | |
| {msg.status === 'done' && msg.attempts > 0 && ( | |
| <div className="flex flex-col gap-2"> | |
| <div className="flex items-center gap-2"> | |
| {msg.feedback ? ( | |
| <div className={`text-xs flex items-center gap-1.5 ${msg.feedback === 'correct' ? 'text-green-400' : 'text-red-400'}`}> | |
| {msg.feedback === 'correct' ? <CheckCircle2 size={12} /> : <XCircle size={12} />} | |
| Marked as {msg.feedback} | |
| </div> | |
| ) : ( | |
| <> | |
| <span className="text-[10px] text-gray-600 mr-0.5">Was this correct?</span> | |
| <button | |
| disabled={msg.feedbackSending} | |
| onClick={() => onFeedback(msg.id, true)} | |
| className="flex items-center gap-1 px-2 py-1 text-[10px] font-medium rounded-lg border border-green-500/25 bg-green-500/8 text-green-400 hover:bg-green-500/15 transition-all disabled:opacity-40" | |
| > | |
| <CheckCircle2 size={10} /> | |
| Correct | |
| </button> | |
| <button | |
| disabled={msg.feedbackSending} | |
| onClick={() => setWrongOpen((v) => !v)} | |
| className={`flex items-center gap-1 px-2 py-1 text-[10px] font-medium rounded-lg border transition-all disabled:opacity-40 ${ | |
| wrongOpen | |
| ? 'border-red-500/40 bg-red-500/15 text-red-300' | |
| : 'border-red-500/25 bg-red-500/8 text-red-400 hover:bg-red-500/15' | |
| }`} | |
| > | |
| <XCircle size={10} /> | |
| Wrong | |
| <ChevronDown size={9} className={`transition-transform ${wrongOpen ? 'rotate-180' : ''}`} /> | |
| </button> | |
| </> | |
| )} | |
| {(msg.status === 'done' || msg.status === 'error') && ( | |
| <button | |
| onClick={() => onRetry(msg.question, msg.feedback === 'wrong' ? msg.sql : undefined)} | |
| className="ml-auto flex items-center gap-1 text-[10px] text-gray-600 hover:text-gray-400 transition-colors" | |
| > | |
| <RefreshCw size={10} /> | |
| {msg.feedback === 'wrong' ? 'Retry differently' : 'Retry'} | |
| </button> | |
| )} | |
| </div> | |
| {/* Remarks dropdown for Wrong */} | |
| <AnimatePresence> | |
| {wrongOpen && !msg.feedback && ( | |
| <motion.div | |
| initial={{ height: 0, opacity: 0 }} | |
| animate={{ height: 'auto', opacity: 1 }} | |
| exit={{ height: 0, opacity: 0 }} | |
| transition={{ duration: 0.15 }} | |
| className="overflow-hidden" | |
| > | |
| <div className="flex flex-col gap-2 p-3 rounded-xl border border-red-500/20 bg-red-500/5"> | |
| <p className="text-[10px] text-red-400/80 font-medium"> | |
| What was wrong? <span className="text-gray-600">(optional — helps improve the prompt)</span> | |
| </p> | |
| <textarea | |
| value={remark} | |
| onChange={(e) => setRemark(e.target.value)} | |
| placeholder="e.g. wrong JOIN logic, missing GROUP BY, incorrect column name…" | |
| rows={2} | |
| className="w-full px-3 py-2 text-xs rounded-lg border border-white/10 bg-black/20 text-gray-300 placeholder-gray-600 resize-none focus:outline-none focus:border-red-500/40 transition-colors" | |
| /> | |
| <div className="flex items-center gap-2 justify-end"> | |
| <button | |
| onClick={() => { setWrongOpen(false); setRemark('') }} | |
| className="text-[10px] text-gray-600 hover:text-gray-400 transition-colors px-2 py-1" | |
| > | |
| Cancel | |
| </button> | |
| <button | |
| disabled={msg.feedbackSending} | |
| onClick={() => { | |
| void onFeedback(msg.id, false, remark.trim() || undefined) | |
| setWrongOpen(false) | |
| setRemark('') | |
| }} | |
| className="flex items-center gap-1 px-3 py-1 text-[10px] font-semibold rounded-lg bg-red-500/20 border border-red-500/30 text-red-300 hover:bg-red-500/30 transition-all disabled:opacity-40" | |
| > | |
| {msg.feedbackSending ? <Loader2 size={9} className="animate-spin" /> : <XCircle size={9} />} | |
| Submit feedback | |
| </button> | |
| </div> | |
| </div> | |
| </motion.div> | |
| )} | |
| </AnimatePresence> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| ) | |
| } | |
| // ─── Chat Panel ─────────────────────────────────────────────────── | |
| export function ChatPanel() { | |
| const { | |
| messages, addMessage, updateMessage, clearMessages, | |
| isExecuting, setIsExecuting, | |
| taskId, taskDifficulty, | |
| optimizingBanner, setOptimizingBanner, | |
| promptGeneration, | |
| isCustomDb, customDbSuggestions, | |
| } = useStore() | |
| const [input, setInput] = useState('') | |
| const bottomRef = useRef<HTMLDivElement>(null) | |
| const inputRef = useRef<HTMLTextAreaElement>(null) | |
| useEffect(() => { | |
| bottomRef.current?.scrollIntoView({ behavior: 'smooth' }) | |
| }, [messages.length]) | |
| const handleFeedback = useCallback( | |
| async (id: string, correct: boolean, remark?: string) => { | |
| const msg = messages.find((m) => m.id === id) | |
| if (!msg) return | |
| updateMessage(id, { feedbackSending: true }) | |
| try { | |
| await submitFeedback(msg.question, msg.sql, correct, remark) | |
| updateMessage(id, { feedback: correct ? 'correct' : 'wrong', feedbackSending: false }) | |
| } catch { | |
| updateMessage(id, { feedbackSending: false }) | |
| } | |
| }, | |
| [messages, updateMessage] | |
| ) | |
| const execute = useCallback( | |
| async (question: string, previousSql?: string) => { | |
| if (!question.trim() || isExecuting) return | |
| setIsExecuting(true) | |
| const msgId = `msg-${Date.now()}` | |
| const newMsg: ChatMessage = { | |
| id: msgId, | |
| question, | |
| status: 'streaming', | |
| sql: '', | |
| rows: [], | |
| rowCount: 0, | |
| attempts: 0, | |
| steps: [], | |
| feedback: null, | |
| promptGeneration, | |
| } | |
| addMessage(newMsg) | |
| try { | |
| for await (const event of streamExecuteQuery(question, taskId, previousSql)) { | |
| if (event.type === 'attempt_start') { | |
| // New attempt — clear SQL so previous attempt's SQL doesn't bleed through | |
| if ((event.attempt as number) > 1) { | |
| updateMessage(msgId, { sql: '', attempts: event.attempt as number }) | |
| } | |
| } else if (event.type === 'sql') { | |
| updateMessage(msgId, { sql: event.sql as string }) | |
| } else if (event.type === 'sql_chunk') { | |
| // incremental SQL streaming — read current sql from store | |
| const curSql = useStore.getState().messages.find((m) => m.id === msgId)?.sql ?? '' | |
| updateMessage(msgId, { sql: curSql + (event.chunk as string) }) | |
| } else if (event.type === 'attempt') { | |
| const step: AttemptStep = { | |
| attempt: event.attempt as number, | |
| sql: event.sql as string, | |
| error: event.error as string | undefined, | |
| action: event.action as string | undefined, | |
| actionScore: event.action_score as number | undefined, | |
| reward: event.reward as number | undefined, | |
| } | |
| const curSteps = useStore.getState().messages.find((m) => m.id === msgId)?.steps ?? [] | |
| updateMessage(msgId, { | |
| attempts: event.attempt as number, | |
| steps: [...curSteps, step], | |
| sql: event.sql as string, | |
| rlAction: event.action as string | undefined, | |
| rlActionScore: event.action_score as number | undefined, | |
| }) | |
| } else if (event.type === 'result') { | |
| updateMessage(msgId, { | |
| rows: (event.rows as Record<string, unknown>[]) ?? [], | |
| rowCount: (event.row_count as number) ?? 0, | |
| reward: event.reward as number | undefined, | |
| }) | |
| } else if (event.type === 'done') { | |
| updateMessage(msgId, { | |
| status: 'done', | |
| attempts: (event.attempts as number) ?? 1, | |
| reward: event.reward as number | undefined, | |
| }) | |
| } else if (event.type === 'error') { | |
| updateMessage(msgId, { | |
| status: 'error', | |
| errorMsg: ((event.message ?? event.error) as string | undefined) ?? 'Agent exhausted all repair attempts', | |
| }) | |
| } else if (event.type === 'gepa_start') { | |
| setOptimizingBanner(true) | |
| } else if (event.type === 'gepa_done') { | |
| setOptimizingBanner(false) | |
| // Refresh prompt history in right sidebar | |
| fetchPromptHistory() | |
| .then((data) => useStore.getState().setPromptData(data)) | |
| .catch(() => { /* noop */ }) | |
| } | |
| } | |
| } catch (err) { | |
| updateMessage(msgId, { | |
| status: 'error', | |
| errorMsg: err instanceof Error ? err.message : 'Network error', | |
| }) | |
| } finally { | |
| setIsExecuting(false) | |
| // If still streaming after generator ends, mark done | |
| const finalMsg = useStore.getState().messages.find((m) => m.id === msgId) | |
| if (finalMsg?.status === 'streaming') { | |
| updateMessage(msgId, { status: finalMsg.sql ? 'done' : 'error' }) | |
| } | |
| } | |
| }, | |
| [isExecuting, setIsExecuting, addMessage, updateMessage, taskId, promptGeneration, setOptimizingBanner] | |
| ) | |
| const handleSend = () => { | |
| if (!input.trim()) return | |
| const q = input.trim() | |
| setInput('') | |
| void execute(q) | |
| } | |
| const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => { | |
| if (e.key === 'Enter' && !e.shiftKey) { | |
| e.preventDefault() | |
| handleSend() | |
| } | |
| } | |
| const suggestions = isCustomDb | |
| ? customDbSuggestions | |
| : (SUGGESTED[taskDifficulty] ?? SUGGESTED.easy) | |
| return ( | |
| <div className="flex flex-col h-full"> | |
| {/* Optimizing banner */} | |
| <AnimatePresence> | |
| {optimizingBanner && ( | |
| <motion.div | |
| initial={{ height: 0, opacity: 0 }} | |
| animate={{ height: 'auto', opacity: 1 }} | |
| exit={{ height: 0, opacity: 0 }} | |
| className="shrink-0 overflow-hidden" | |
| > | |
| <div className="shimmer-banner border-b border-violet-500/20 px-4 py-2 flex items-center gap-2"> | |
| <Loader2 size={12} className="animate-spin text-violet-400" /> | |
| <span className="text-xs text-violet-300 font-semibold"> | |
| Optimizing system prompt via GEPA... | |
| </span> | |
| </div> | |
| </motion.div> | |
| )} | |
| </AnimatePresence> | |
| {/* Messages */} | |
| <div className="flex-1 overflow-y-auto px-4 py-4"> | |
| {messages.length === 0 ? ( | |
| <EmptyState onSelect={(q) => { setInput(q); inputRef.current?.focus() }} /> | |
| ) : ( | |
| <div className="flex flex-col gap-6 max-w-3xl mx-auto"> | |
| {messages.map((msg) => ( | |
| <MessageCard | |
| key={msg.id} | |
| msg={msg} | |
| onFeedback={handleFeedback} | |
| onRetry={(q, prevSql) => void execute(q, prevSql)} | |
| /> | |
| ))} | |
| <div ref={bottomRef} /> | |
| </div> | |
| )} | |
| </div> | |
| {/* Input area */} | |
| <div | |
| className="shrink-0 border-t border-white/[0.06] px-4 py-3" | |
| style={{ background: 'var(--bg-secondary)' }} | |
| > | |
| {/* Suggested chips */} | |
| {messages.length > 0 && ( | |
| <div className="flex gap-1.5 flex-wrap mb-2.5"> | |
| {suggestions.slice(0, 3).map((q) => ( | |
| <button | |
| key={q} | |
| onClick={() => { setInput(q); inputRef.current?.focus() }} | |
| className="text-[10px] px-2.5 py-1 rounded-full border border-white/[0.06] text-gray-500 hover:text-gray-300 hover:border-violet-500/30 transition-all" | |
| > | |
| {q} | |
| </button> | |
| ))} | |
| </div> | |
| )} | |
| <div className="flex items-end gap-2"> | |
| <div className="flex-1 relative"> | |
| <textarea | |
| ref={inputRef} | |
| value={input} | |
| onChange={(e) => setInput(e.target.value)} | |
| onKeyDown={handleKeyDown} | |
| placeholder={isCustomDb ? 'Ask anything about your data…' : 'Ask about products, orders, sellers...'} | |
| disabled={isExecuting} | |
| rows={1} | |
| className="w-full px-3 py-2.5 pr-10 text-sm text-white rounded-xl border border-white/[0.06] bg-white/[0.03] placeholder-gray-600 resize-none focus:outline-none focus:border-violet-500/40 focus:bg-white/[0.05] transition-all disabled:opacity-50" | |
| style={{ minHeight: 40, maxHeight: 120, overflowY: 'auto' }} | |
| /> | |
| </div> | |
| <div className="flex flex-col gap-1.5 shrink-0"> | |
| <button | |
| onClick={handleSend} | |
| disabled={!input.trim() || isExecuting} | |
| className="w-9 h-9 rounded-xl bg-violet-600 hover:bg-violet-500 disabled:opacity-40 disabled:cursor-not-allowed transition-all flex items-center justify-center" | |
| > | |
| {isExecuting ? ( | |
| <Loader2 size={14} className="animate-spin text-white" /> | |
| ) : ( | |
| <Send size={14} className="text-white" /> | |
| )} | |
| </button> | |
| {messages.length > 0 && ( | |
| <button | |
| onClick={clearMessages} | |
| disabled={isExecuting} | |
| className="w-9 h-9 rounded-xl border border-white/[0.06] hover:bg-white/5 disabled:opacity-40 transition-all flex items-center justify-center text-gray-600 hover:text-gray-400" | |
| title="Clear chat" | |
| > | |
| <Trash2 size={12} /> | |
| </button> | |
| )} | |
| </div> | |
| </div> | |
| <p className="text-[9px] text-gray-700 mt-1.5 text-center"> | |
| Enter to send · Shift+Enter for newline · Agent uses LinUCB + GEPA | |
| </p> | |
| </div> | |
| </div> | |
| ) | |
| } | |