ar9avg's picture
fix
17e7bd7
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>
)
}