Spaces:
Sleeping
Sleeping
feat: add Demo button with scripted autoplay showcase
Browse files- frontend/src/App.tsx +7 -0
- frontend/src/components/DemoMode.tsx +908 -0
- frontend/src/components/Header.tsx +13 -2
frontend/src/App.tsx
CHANGED
|
@@ -8,6 +8,7 @@ import { ChatPanel } from './components/ChatPanel'
|
|
| 8 |
import { BenchmarkPanel } from './components/BenchmarkPanel'
|
| 9 |
import { ERDiagram } from './components/ERDiagram'
|
| 10 |
import { RightSidebar } from './components/RightSidebar'
|
|
|
|
| 11 |
import { useStore } from './store/useStore'
|
| 12 |
import { fetchInit } from './lib/api'
|
| 13 |
|
|
@@ -23,6 +24,7 @@ export default function App() {
|
|
| 23 |
const [activeTab, setActiveTab] = useState<Tab>('chat')
|
| 24 |
const [leftOpen, setLeftOpen] = useState(false)
|
| 25 |
const [rightOpen, setRightOpen] = useState(false)
|
|
|
|
| 26 |
|
| 27 |
const { theme, setDbSeeded, setTables, setSchemaGraph } = useStore()
|
| 28 |
|
|
@@ -71,8 +73,13 @@ export default function App() {
|
|
| 71 |
<Header
|
| 72 |
onToggleLeft={() => { setLeftOpen((v) => !v); setRightOpen(false) }}
|
| 73 |
onToggleRight={() => { setRightOpen((v) => !v); setLeftOpen(false) }}
|
|
|
|
| 74 |
/>
|
| 75 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
<div className="flex flex-1 overflow-hidden relative">
|
| 77 |
{/* Overlay backdrop (mobile) */}
|
| 78 |
{(leftOpen || rightOpen) && (
|
|
|
|
| 8 |
import { BenchmarkPanel } from './components/BenchmarkPanel'
|
| 9 |
import { ERDiagram } from './components/ERDiagram'
|
| 10 |
import { RightSidebar } from './components/RightSidebar'
|
| 11 |
+
import { DemoMode } from './components/DemoMode'
|
| 12 |
import { useStore } from './store/useStore'
|
| 13 |
import { fetchInit } from './lib/api'
|
| 14 |
|
|
|
|
| 24 |
const [activeTab, setActiveTab] = useState<Tab>('chat')
|
| 25 |
const [leftOpen, setLeftOpen] = useState(false)
|
| 26 |
const [rightOpen, setRightOpen] = useState(false)
|
| 27 |
+
const [demoOpen, setDemoOpen] = useState(false)
|
| 28 |
|
| 29 |
const { theme, setDbSeeded, setTables, setSchemaGraph } = useStore()
|
| 30 |
|
|
|
|
| 73 |
<Header
|
| 74 |
onToggleLeft={() => { setLeftOpen((v) => !v); setRightOpen(false) }}
|
| 75 |
onToggleRight={() => { setRightOpen((v) => !v); setLeftOpen(false) }}
|
| 76 |
+
onDemo={() => setDemoOpen(true)}
|
| 77 |
/>
|
| 78 |
|
| 79 |
+
<AnimatePresence>
|
| 80 |
+
{demoOpen && <DemoMode onClose={() => setDemoOpen(false)} />}
|
| 81 |
+
</AnimatePresence>
|
| 82 |
+
|
| 83 |
<div className="flex flex-1 overflow-hidden relative">
|
| 84 |
{/* Overlay backdrop (mobile) */}
|
| 85 |
{(leftOpen || rightOpen) && (
|
frontend/src/components/DemoMode.tsx
ADDED
|
@@ -0,0 +1,908 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* DemoMode β scripted autoplay showcase for SQL Agent OpenEnv.
|
| 3 |
+
*
|
| 4 |
+
* Demonstrates: SQL generation β RL repair loop β GEPA prompt evolution β improvement.
|
| 5 |
+
* Fully client-side, no real API calls. Mirrors the /showcase pattern from the reference app.
|
| 6 |
+
*/
|
| 7 |
+
|
| 8 |
+
import { useState, useRef, useCallback, useEffect } from 'react'
|
| 9 |
+
import { motion, AnimatePresence } from 'framer-motion'
|
| 10 |
+
import {
|
| 11 |
+
Play, X, RotateCcw, Zap, CheckCircle2, XCircle,
|
| 12 |
+
ChevronDown, ChevronUp, Sparkles, TrendingUp, Loader2,
|
| 13 |
+
} from 'lucide-react'
|
| 14 |
+
|
| 15 |
+
// βββ Scripted data ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 16 |
+
|
| 17 |
+
const PROMPTS = [
|
| 18 |
+
// Gen 0 β baseline
|
| 19 |
+
`You are a SQL expert. Given a question and a SQLite database schema, write correct SQL.
|
| 20 |
+
|
| 21 |
+
Rules:
|
| 22 |
+
- Output ONLY the SQL query
|
| 23 |
+
- Use SQLite syntax
|
| 24 |
+
- No markdown, no code fences`,
|
| 25 |
+
|
| 26 |
+
// Gen 1 β after first GEPA cycle
|
| 27 |
+
`You are a SQL expert. Given a question and a SQLite database schema, write correct SQL.
|
| 28 |
+
|
| 29 |
+
Rules:
|
| 30 |
+
- Output ONLY the SQL query
|
| 31 |
+
- Use SQLite syntax
|
| 32 |
+
- No markdown, no code fences
|
| 33 |
+
- Always qualify column names with table aliases in JOINs
|
| 34 |
+
- Use t.column_name format to avoid ambiguous column errors
|
| 35 |
+
- Check schema carefully before referencing any column name`,
|
| 36 |
+
|
| 37 |
+
// Gen 2 β after second GEPA cycle
|
| 38 |
+
`You are a SQL expert. Given a question and a SQLite database schema, write correct SQL.
|
| 39 |
+
|
| 40 |
+
Rules:
|
| 41 |
+
- Output ONLY the SQL query
|
| 42 |
+
- Use SQLite syntax
|
| 43 |
+
- No markdown, no code fences
|
| 44 |
+
- Always qualify column names with table aliases in JOINs
|
| 45 |
+
- Use t.column_name format to avoid ambiguous column errors
|
| 46 |
+
- Check schema carefully before referencing any column name
|
| 47 |
+
- For aggregations: always include non-aggregated columns in GROUP BY
|
| 48 |
+
- For revenue calculations: use orders.total_price, not price
|
| 49 |
+
- For rankings: use ORDER BY β¦ DESC LIMIT N`,
|
| 50 |
+
]
|
| 51 |
+
|
| 52 |
+
const SCORES = [0.42, 0.74, 0.91]
|
| 53 |
+
|
| 54 |
+
interface Attempt {
|
| 55 |
+
sql: string
|
| 56 |
+
error?: string
|
| 57 |
+
errorClass?: string
|
| 58 |
+
rlAction?: string
|
| 59 |
+
reward: number
|
| 60 |
+
rows?: Record<string, string | number>[]
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
interface QueryDef {
|
| 64 |
+
id: string
|
| 65 |
+
question: string
|
| 66 |
+
badge: string
|
| 67 |
+
difficulty: 'Easy' | 'Medium' | 'Hard'
|
| 68 |
+
attempts: Attempt[]
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
const QUERIES: Record<string, QueryDef> = {
|
| 72 |
+
q1r1: {
|
| 73 |
+
id: 'q1r1', question: 'Show all products', badge: 'Easy',
|
| 74 |
+
difficulty: 'Easy',
|
| 75 |
+
attempts: [
|
| 76 |
+
{
|
| 77 |
+
sql: 'SELECT * FROM product',
|
| 78 |
+
error: 'no such table: product',
|
| 79 |
+
errorClass: 'NO_SUCH_TABLE',
|
| 80 |
+
rlAction: 'FIX_TABLE',
|
| 81 |
+
reward: -0.15,
|
| 82 |
+
},
|
| 83 |
+
{
|
| 84 |
+
sql: 'SELECT * FROM products',
|
| 85 |
+
reward: 0.90,
|
| 86 |
+
rows: [
|
| 87 |
+
{ id: 1, name: 'Wireless Headphones', category: 'Electronics', price: 79.99 },
|
| 88 |
+
{ id: 2, name: 'Running Shoes', category: 'Footwear', price: 59.99 },
|
| 89 |
+
{ id: 3, name: 'Coffee Maker', category: 'Kitchen', price: 49.99 },
|
| 90 |
+
],
|
| 91 |
+
},
|
| 92 |
+
],
|
| 93 |
+
},
|
| 94 |
+
q2r1: {
|
| 95 |
+
id: 'q2r1', question: 'Top 5 sellers by total revenue',
|
| 96 |
+
badge: 'Medium', difficulty: 'Medium',
|
| 97 |
+
attempts: [
|
| 98 |
+
{
|
| 99 |
+
sql: `SELECT seller_id, SUM(price) as revenue
|
| 100 |
+
FROM orders
|
| 101 |
+
GROUP BY seller_id
|
| 102 |
+
ORDER BY revenue DESC
|
| 103 |
+
LIMIT 5`,
|
| 104 |
+
error: 'no such column: price',
|
| 105 |
+
errorClass: 'NO_SUCH_COLUMN',
|
| 106 |
+
rlAction: 'FIX_COLUMN',
|
| 107 |
+
reward: -0.20,
|
| 108 |
+
},
|
| 109 |
+
{
|
| 110 |
+
sql: `SELECT seller_id, SUM(total_price) as revenue
|
| 111 |
+
FROM orders
|
| 112 |
+
GROUP BY seller_id
|
| 113 |
+
ORDER BY revenue DESC
|
| 114 |
+
LIMIT 5`,
|
| 115 |
+
error: 'ambiguous column name: seller_id',
|
| 116 |
+
errorClass: 'AMBIGUOUS_COLUMN',
|
| 117 |
+
rlAction: 'FIX_TABLE',
|
| 118 |
+
reward: -0.25,
|
| 119 |
+
},
|
| 120 |
+
{
|
| 121 |
+
sql: `SELECT s.name, SUM(o.total_price) as revenue
|
| 122 |
+
FROM orders o
|
| 123 |
+
JOIN sellers s ON o.user_id = s.id
|
| 124 |
+
GROUP BY s.id, s.name
|
| 125 |
+
ORDER BY revenue DESC
|
| 126 |
+
LIMIT 5`,
|
| 127 |
+
reward: 0.70,
|
| 128 |
+
rows: [
|
| 129 |
+
{ name: 'TechStore Pro', revenue: 12840 },
|
| 130 |
+
{ name: 'StyleHub', revenue: 9320 },
|
| 131 |
+
{ name: 'GadgetWorld', revenue: 8750 },
|
| 132 |
+
{ name: 'HomeGoods Co', revenue: 7200 },
|
| 133 |
+
{ name: 'SportZone', revenue: 6100 },
|
| 134 |
+
],
|
| 135 |
+
},
|
| 136 |
+
],
|
| 137 |
+
},
|
| 138 |
+
q3r1: {
|
| 139 |
+
id: 'q3r1', question: 'Average order value by country',
|
| 140 |
+
badge: 'Medium', difficulty: 'Medium',
|
| 141 |
+
attempts: [
|
| 142 |
+
{
|
| 143 |
+
sql: `SELECT country, AVG(total_price) as avg_order
|
| 144 |
+
FROM orders
|
| 145 |
+
JOIN users ON user_id = users.id
|
| 146 |
+
GROUP BY country`,
|
| 147 |
+
error: 'ambiguous column name: country',
|
| 148 |
+
errorClass: 'AMBIGUOUS_COLUMN',
|
| 149 |
+
rlAction: 'FIX_COLUMN',
|
| 150 |
+
reward: -0.15,
|
| 151 |
+
},
|
| 152 |
+
{
|
| 153 |
+
sql: `SELECT u.country, ROUND(AVG(o.total_price), 2) as avg_order
|
| 154 |
+
FROM orders o
|
| 155 |
+
JOIN users u ON o.user_id = u.id
|
| 156 |
+
GROUP BY u.country
|
| 157 |
+
ORDER BY avg_order DESC`,
|
| 158 |
+
reward: 0.85,
|
| 159 |
+
rows: [
|
| 160 |
+
{ country: 'USA', avg_order: 142.50 },
|
| 161 |
+
{ country: 'UK', avg_order: 138.20 },
|
| 162 |
+
{ country: 'Germany', avg_order: 121.80 },
|
| 163 |
+
{ country: 'Canada', avg_order: 118.40 },
|
| 164 |
+
],
|
| 165 |
+
},
|
| 166 |
+
],
|
| 167 |
+
},
|
| 168 |
+
// Round 2 β after GEPA, succeed faster
|
| 169 |
+
q1r2: {
|
| 170 |
+
id: 'q1r2', question: 'Show all products',
|
| 171 |
+
badge: 'Easy', difficulty: 'Easy',
|
| 172 |
+
attempts: [
|
| 173 |
+
{
|
| 174 |
+
sql: 'SELECT * FROM products',
|
| 175 |
+
reward: 1.00,
|
| 176 |
+
rows: [
|
| 177 |
+
{ id: 1, name: 'Wireless Headphones', category: 'Electronics', price: 79.99 },
|
| 178 |
+
{ id: 2, name: 'Running Shoes', category: 'Footwear', price: 59.99 },
|
| 179 |
+
{ id: 3, name: 'Coffee Maker', category: 'Kitchen', price: 49.99 },
|
| 180 |
+
],
|
| 181 |
+
},
|
| 182 |
+
],
|
| 183 |
+
},
|
| 184 |
+
q2r2: {
|
| 185 |
+
id: 'q2r2', question: 'Top 5 sellers by total revenue',
|
| 186 |
+
badge: 'Medium', difficulty: 'Medium',
|
| 187 |
+
attempts: [
|
| 188 |
+
{
|
| 189 |
+
sql: `SELECT s.name, SUM(o.total_price) as revenue
|
| 190 |
+
FROM orders o
|
| 191 |
+
JOIN sellers s ON o.user_id = s.id
|
| 192 |
+
GROUP BY s.id, s.name
|
| 193 |
+
ORDER BY revenue DESC
|
| 194 |
+
LIMIT 5`,
|
| 195 |
+
reward: 0.90,
|
| 196 |
+
rows: [
|
| 197 |
+
{ name: 'TechStore Pro', revenue: 12840 },
|
| 198 |
+
{ name: 'StyleHub', revenue: 9320 },
|
| 199 |
+
{ name: 'GadgetWorld', revenue: 8750 },
|
| 200 |
+
],
|
| 201 |
+
},
|
| 202 |
+
],
|
| 203 |
+
},
|
| 204 |
+
q3r2: {
|
| 205 |
+
id: 'q3r2', question: 'Average order value by country',
|
| 206 |
+
badge: 'Medium', difficulty: 'Medium',
|
| 207 |
+
attempts: [
|
| 208 |
+
{
|
| 209 |
+
sql: `SELECT u.country, ROUND(AVG(o.total_price), 2) as avg_order
|
| 210 |
+
FROM orders o
|
| 211 |
+
JOIN users u ON o.user_id = u.id
|
| 212 |
+
GROUP BY u.country
|
| 213 |
+
ORDER BY avg_order DESC`,
|
| 214 |
+
reward: 1.00,
|
| 215 |
+
rows: [
|
| 216 |
+
{ country: 'USA', avg_order: 142.50 },
|
| 217 |
+
{ country: 'UK', avg_order: 138.20 },
|
| 218 |
+
{ country: 'Germany', avg_order: 121.80 },
|
| 219 |
+
],
|
| 220 |
+
},
|
| 221 |
+
],
|
| 222 |
+
},
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
const ROUND_1 = ['q1r1', 'q2r1', 'q3r1']
|
| 226 |
+
const ROUND_2 = ['q1r2', 'q2r2', 'q3r2']
|
| 227 |
+
|
| 228 |
+
// βββ Helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 229 |
+
|
| 230 |
+
let _idCounter = 0
|
| 231 |
+
const uid = () => `bubble-${++_idCounter}`
|
| 232 |
+
|
| 233 |
+
function sleep(ms: number): Promise<void> {
|
| 234 |
+
return new Promise((res) => setTimeout(res, ms))
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
type AppState = 'idle' | 'running' | 'gepa' | 'done'
|
| 238 |
+
|
| 239 |
+
interface BaseBubble { id: string }
|
| 240 |
+
interface UserBubble extends BaseBubble { type: 'user'; text: string }
|
| 241 |
+
interface ThinkingBubble extends BaseBubble { type: 'thinking'; label: string }
|
| 242 |
+
interface SqlStreamBubble extends BaseBubble { type: 'sql_stream'; sql: string; attempt: number }
|
| 243 |
+
interface SqlErrBubble extends BaseBubble { type: 'sql_err'; sql: string; error: string; errorClass: string; rlAction: string; reward: number; attempt: number }
|
| 244 |
+
interface SqlOkBubble extends BaseBubble { type: 'sql_ok'; sql: string; rows: Record<string, string | number>[]; reward: number; attempt: number; badge: string; firstTry: boolean }
|
| 245 |
+
interface GepaBubble extends BaseBubble { type: 'gepa'; fromGen: number; toGen: number; scoreFrom: number; scoreTo: number }
|
| 246 |
+
interface GroupBubble extends BaseBubble { type: 'group'; question: string; badge: string; difficulty: string; success: boolean; attempts: number; children: BubbleData[] }
|
| 247 |
+
|
| 248 |
+
type BubbleData = UserBubble | ThinkingBubble | SqlStreamBubble | SqlErrBubble | SqlOkBubble | GepaBubble | GroupBubble
|
| 249 |
+
|
| 250 |
+
// βββ SQL keyword highlighter ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 251 |
+
|
| 252 |
+
function HighlightSQL({ sql }: { sql: string }) {
|
| 253 |
+
const keywords = /\b(SELECT|FROM|WHERE|JOIN|LEFT|RIGHT|ON|GROUP BY|ORDER BY|HAVING|LIMIT|AS|AND|OR|SUM|AVG|COUNT|ROUND|DISTINCT|DESC|ASC|NULL|NOT|IN|IS)\b/gi
|
| 254 |
+
const parts: React.ReactNode[] = []
|
| 255 |
+
let last = 0
|
| 256 |
+
let match: RegExpExecArray | null
|
| 257 |
+
const re = new RegExp(keywords.source, 'gi')
|
| 258 |
+
while ((match = re.exec(sql)) !== null) {
|
| 259 |
+
if (match.index > last) parts.push(<span key={`t${last}`}>{sql.slice(last, match.index)}</span>)
|
| 260 |
+
parts.push(<span key={`k${match.index}`} className="text-violet-300 font-semibold">{match[0]}</span>)
|
| 261 |
+
last = match.index + match[0].length
|
| 262 |
+
}
|
| 263 |
+
if (last < sql.length) parts.push(<span key="tend">{sql.slice(last)}</span>)
|
| 264 |
+
return <>{parts}</>
|
| 265 |
+
}
|
| 266 |
+
|
| 267 |
+
// βββ Bubble renderers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 268 |
+
|
| 269 |
+
function Bubble({ b }: { b: BubbleData }) {
|
| 270 |
+
const [open, setOpen] = useState(false)
|
| 271 |
+
|
| 272 |
+
if (b.type === 'user') return (
|
| 273 |
+
<div className="flex justify-end">
|
| 274 |
+
<div className="max-w-[75%] bg-violet-600/20 border border-violet-500/25 rounded-2xl rounded-tr-sm px-4 py-2.5">
|
| 275 |
+
<p className="text-sm text-white">{b.text}</p>
|
| 276 |
+
</div>
|
| 277 |
+
</div>
|
| 278 |
+
)
|
| 279 |
+
|
| 280 |
+
if (b.type === 'thinking') return (
|
| 281 |
+
<div className="flex items-center gap-2 text-xs text-gray-500 px-1">
|
| 282 |
+
<Loader2 size={11} className="animate-spin text-violet-400 shrink-0" />
|
| 283 |
+
{b.label}
|
| 284 |
+
</div>
|
| 285 |
+
)
|
| 286 |
+
|
| 287 |
+
if (b.type === 'sql_stream') return (
|
| 288 |
+
<div className="border border-white/[0.06] rounded-xl overflow-hidden">
|
| 289 |
+
<div className="px-3 py-1.5 bg-white/[0.02] flex items-center gap-2">
|
| 290 |
+
<span className="text-[10px] font-semibold text-gray-500 uppercase tracking-wider">SQL</span>
|
| 291 |
+
<span className="text-[10px] text-gray-600">Attempt {b.attempt}</span>
|
| 292 |
+
<Loader2 size={9} className="animate-spin text-violet-400 ml-auto" />
|
| 293 |
+
</div>
|
| 294 |
+
<pre className="px-3 py-2 text-xs leading-relaxed whitespace-pre-wrap" style={{ color: 'rgba(221,214,254,0.8)', background: 'rgba(139,92,246,0.06)' }}>
|
| 295 |
+
<HighlightSQL sql={b.sql} />
|
| 296 |
+
<span className="inline-block w-0.5 h-[1em] bg-violet-400 animate-pulse align-bottom ml-0.5" />
|
| 297 |
+
</pre>
|
| 298 |
+
</div>
|
| 299 |
+
)
|
| 300 |
+
|
| 301 |
+
if (b.type === 'sql_err') return (
|
| 302 |
+
<div className="flex flex-col gap-1.5">
|
| 303 |
+
<div className="border border-white/[0.06] rounded-xl overflow-hidden">
|
| 304 |
+
<div className="px-3 py-1.5 bg-white/[0.02] flex items-center gap-2">
|
| 305 |
+
<span className="text-[10px] font-semibold text-gray-500 uppercase tracking-wider">SQL</span>
|
| 306 |
+
<span className="text-[10px] text-gray-600">Attempt {b.attempt}</span>
|
| 307 |
+
</div>
|
| 308 |
+
<pre className="px-3 py-2 text-xs leading-relaxed whitespace-pre-wrap" style={{ color: 'rgba(221,214,254,0.8)', background: 'rgba(139,92,246,0.06)' }}>
|
| 309 |
+
<HighlightSQL sql={b.sql} />
|
| 310 |
+
</pre>
|
| 311 |
+
</div>
|
| 312 |
+
<div className="flex items-start gap-2 bg-red-500/10 border border-red-500/20 rounded-xl px-3 py-2 text-xs text-red-300">
|
| 313 |
+
<XCircle size={11} className="shrink-0 mt-0.5" />
|
| 314 |
+
<div className="flex-1">
|
| 315 |
+
<div className="flex items-center gap-2 flex-wrap">
|
| 316 |
+
<span>{b.error}</span>
|
| 317 |
+
<span className="text-[10px] px-1.5 py-0.5 bg-red-500/15 rounded-full text-red-400">{b.errorClass}</span>
|
| 318 |
+
</div>
|
| 319 |
+
</div>
|
| 320 |
+
<div className="flex items-center gap-1.5 shrink-0">
|
| 321 |
+
<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">
|
| 322 |
+
<Zap size={8} />{b.rlAction}
|
| 323 |
+
</span>
|
| 324 |
+
<span className="text-[11px] font-bold text-red-400">{b.reward.toFixed(2)}</span>
|
| 325 |
+
</div>
|
| 326 |
+
</div>
|
| 327 |
+
</div>
|
| 328 |
+
)
|
| 329 |
+
|
| 330 |
+
if (b.type === 'sql_ok') return (
|
| 331 |
+
<div className="flex flex-col gap-1.5">
|
| 332 |
+
<div className="border border-white/[0.06] rounded-xl overflow-hidden">
|
| 333 |
+
<div className="px-3 py-1.5 bg-white/[0.02] flex items-center gap-2">
|
| 334 |
+
<span className="text-[10px] font-semibold text-gray-500 uppercase tracking-wider">SQL</span>
|
| 335 |
+
{b.firstTry && (
|
| 336 |
+
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-green-500/15 border border-green-500/25 text-green-400 font-semibold">first try</span>
|
| 337 |
+
)}
|
| 338 |
+
</div>
|
| 339 |
+
<pre className="px-3 py-2 text-xs leading-relaxed whitespace-pre-wrap" style={{ color: 'rgba(221,214,254,0.8)', background: 'rgba(139,92,246,0.06)' }}>
|
| 340 |
+
<HighlightSQL sql={b.sql} />
|
| 341 |
+
</pre>
|
| 342 |
+
</div>
|
| 343 |
+
<div className="flex items-center gap-2 text-[10px] px-0.5">
|
| 344 |
+
<CheckCircle2 size={11} className="text-green-400" />
|
| 345 |
+
<span className="text-green-400 font-semibold">Success</span>
|
| 346 |
+
<span className="text-gray-600">Β· {b.rows.length} rows</span>
|
| 347 |
+
<span className="ml-auto text-[11px] font-bold text-green-400">+{b.reward.toFixed(2)}</span>
|
| 348 |
+
</div>
|
| 349 |
+
{/* Results mini-table */}
|
| 350 |
+
<div className="rounded-xl border border-white/[0.06] overflow-hidden text-[10px]">
|
| 351 |
+
<div className="grid overflow-x-auto">
|
| 352 |
+
<table className="w-full">
|
| 353 |
+
<thead>
|
| 354 |
+
<tr className="bg-white/[0.03]">
|
| 355 |
+
{Object.keys(b.rows[0] ?? {}).map((k) => (
|
| 356 |
+
<th key={k} className="px-2 py-1.5 text-left font-semibold text-gray-500 whitespace-nowrap">{k}</th>
|
| 357 |
+
))}
|
| 358 |
+
</tr>
|
| 359 |
+
</thead>
|
| 360 |
+
<tbody>
|
| 361 |
+
{b.rows.map((row, i) => (
|
| 362 |
+
<tr key={i} className={i % 2 === 0 ? 'bg-white/[0.01]' : ''}>
|
| 363 |
+
{Object.values(row).map((v, j) => (
|
| 364 |
+
<td key={j} className="px-2 py-1 text-gray-300 whitespace-nowrap">{String(v)}</td>
|
| 365 |
+
))}
|
| 366 |
+
</tr>
|
| 367 |
+
))}
|
| 368 |
+
</tbody>
|
| 369 |
+
</table>
|
| 370 |
+
</div>
|
| 371 |
+
</div>
|
| 372 |
+
</div>
|
| 373 |
+
)
|
| 374 |
+
|
| 375 |
+
if (b.type === 'gepa') return (
|
| 376 |
+
<div className="border border-violet-500/25 rounded-2xl overflow-hidden bg-violet-500/5">
|
| 377 |
+
<div className="px-4 py-3 flex items-center gap-2 border-b border-violet-500/15">
|
| 378 |
+
<Sparkles size={13} className="text-violet-400" />
|
| 379 |
+
<span className="text-xs font-semibold text-violet-300">GEPA Prompt Evolution</span>
|
| 380 |
+
<span className="ml-auto text-[10px] text-violet-400/70">Gen {b.fromGen} β Gen {b.toGen}</span>
|
| 381 |
+
</div>
|
| 382 |
+
<div className="px-4 py-3 flex items-center gap-4">
|
| 383 |
+
<div className="flex flex-col items-center gap-0.5">
|
| 384 |
+
<div className="text-[10px] text-gray-600">Before</div>
|
| 385 |
+
<div className="text-lg font-bold text-orange-400">{(b.scoreFrom * 100).toFixed(0)}%</div>
|
| 386 |
+
</div>
|
| 387 |
+
<TrendingUp size={18} className="text-violet-400" />
|
| 388 |
+
<div className="flex flex-col items-center gap-0.5">
|
| 389 |
+
<div className="text-[10px] text-gray-600">After</div>
|
| 390 |
+
<div className="text-lg font-bold text-green-400">{(b.scoreTo * 100).toFixed(0)}%</div>
|
| 391 |
+
</div>
|
| 392 |
+
<div className="ml-auto text-[10px] text-gray-500">
|
| 393 |
+
System prompt updated with<br />targeted repair rules
|
| 394 |
+
</div>
|
| 395 |
+
</div>
|
| 396 |
+
</div>
|
| 397 |
+
)
|
| 398 |
+
|
| 399 |
+
if (b.type === 'group') {
|
| 400 |
+
const color = b.difficulty === 'Easy' ? 'text-green-400' : b.difficulty === 'Hard' ? 'text-red-400' : 'text-amber-400'
|
| 401 |
+
return (
|
| 402 |
+
<div className="border border-white/[0.06] rounded-2xl overflow-hidden">
|
| 403 |
+
<button
|
| 404 |
+
onClick={() => setOpen((v) => !v)}
|
| 405 |
+
className="w-full flex items-center gap-2 px-3 py-2.5 bg-white/[0.02] hover:bg-white/[0.04] transition-colors text-left"
|
| 406 |
+
>
|
| 407 |
+
{b.success
|
| 408 |
+
? <CheckCircle2 size={12} className="text-green-400 shrink-0" />
|
| 409 |
+
: <XCircle size={12} className="text-red-400 shrink-0" />
|
| 410 |
+
}
|
| 411 |
+
<span className="text-xs text-gray-300 flex-1 truncate">{b.question}</span>
|
| 412 |
+
<span className={`text-[10px] font-semibold px-1.5 py-0.5 rounded-full border ${color} border-current/30 bg-current/10`}>{b.badge}</span>
|
| 413 |
+
<span className="text-[10px] text-gray-600">{b.attempts} attempt{b.attempts !== 1 ? 's' : ''}</span>
|
| 414 |
+
{open ? <ChevronUp size={11} className="text-gray-600 shrink-0" /> : <ChevronDown size={11} className="text-gray-600 shrink-0" />}
|
| 415 |
+
</button>
|
| 416 |
+
<AnimatePresence>
|
| 417 |
+
{open && (
|
| 418 |
+
<motion.div
|
| 419 |
+
initial={{ height: 0, opacity: 0 }}
|
| 420 |
+
animate={{ height: 'auto', opacity: 1 }}
|
| 421 |
+
exit={{ height: 0, opacity: 0 }}
|
| 422 |
+
transition={{ duration: 0.15 }}
|
| 423 |
+
className="overflow-hidden"
|
| 424 |
+
>
|
| 425 |
+
<div className="p-3 flex flex-col gap-2.5 border-t border-white/[0.04]">
|
| 426 |
+
{b.children.map((child) => <Bubble key={child.id} b={child} />)}
|
| 427 |
+
</div>
|
| 428 |
+
</motion.div>
|
| 429 |
+
)}
|
| 430 |
+
</AnimatePresence>
|
| 431 |
+
</div>
|
| 432 |
+
)
|
| 433 |
+
}
|
| 434 |
+
|
| 435 |
+
return null
|
| 436 |
+
}
|
| 437 |
+
|
| 438 |
+
// βββ Right panel β prompt + score βββββββββββββββββββββββββββββββββββββββββββββ
|
| 439 |
+
|
| 440 |
+
function PromptPanel({ gen, score }: { gen: number; score: number }) {
|
| 441 |
+
const [open, setOpen] = useState(false)
|
| 442 |
+
const prompt = PROMPTS[gen] ?? PROMPTS[0]
|
| 443 |
+
const lines = prompt.split('\n')
|
| 444 |
+
|
| 445 |
+
return (
|
| 446 |
+
<div className="flex flex-col gap-3 px-4 py-4">
|
| 447 |
+
<div className="flex items-center justify-between">
|
| 448 |
+
<div className="flex items-center gap-2">
|
| 449 |
+
<Sparkles size={12} className="text-violet-400" />
|
| 450 |
+
<span className="text-[11px] font-semibold text-gray-400 uppercase tracking-wider">GEPA Prompt</span>
|
| 451 |
+
</div>
|
| 452 |
+
<span className="text-[10px] px-2 py-0.5 rounded-full bg-violet-500/15 border border-violet-500/25 text-violet-400 font-semibold">
|
| 453 |
+
Gen {gen}
|
| 454 |
+
</span>
|
| 455 |
+
</div>
|
| 456 |
+
|
| 457 |
+
{/* Score */}
|
| 458 |
+
<div className="bg-white/[0.03] border border-white/[0.06] rounded-xl p-3 flex items-center gap-3">
|
| 459 |
+
<div>
|
| 460 |
+
<div className="text-[10px] text-gray-600 mb-0.5">Benchmark score</div>
|
| 461 |
+
<div className="text-2xl font-bold text-green-400 tabular-nums">{(score * 100).toFixed(0)}%</div>
|
| 462 |
+
</div>
|
| 463 |
+
<div className="flex-1 h-1.5 bg-white/[0.05] rounded-full overflow-hidden">
|
| 464 |
+
<motion.div
|
| 465 |
+
className="h-full bg-gradient-to-r from-violet-500 to-green-400 rounded-full"
|
| 466 |
+
animate={{ width: `${score * 100}%` }}
|
| 467 |
+
transition={{ duration: 0.8, ease: 'easeOut' }}
|
| 468 |
+
/>
|
| 469 |
+
</div>
|
| 470 |
+
</div>
|
| 471 |
+
|
| 472 |
+
{/* Prompt preview */}
|
| 473 |
+
<div className="border border-white/[0.06] rounded-xl overflow-hidden">
|
| 474 |
+
<button
|
| 475 |
+
onClick={() => setOpen((v) => !v)}
|
| 476 |
+
className="w-full flex items-center justify-between px-3 py-2 bg-white/[0.02] hover:bg-white/[0.04] transition-colors"
|
| 477 |
+
>
|
| 478 |
+
<span className="text-[10px] font-semibold text-gray-500 uppercase tracking-wider">System Prompt</span>
|
| 479 |
+
{open ? <ChevronUp size={11} className="text-gray-600" /> : <ChevronDown size={11} className="text-gray-600" />}
|
| 480 |
+
</button>
|
| 481 |
+
<AnimatePresence>
|
| 482 |
+
{open && (
|
| 483 |
+
<motion.div
|
| 484 |
+
initial={{ height: 0 }}
|
| 485 |
+
animate={{ height: 'auto' }}
|
| 486 |
+
exit={{ height: 0 }}
|
| 487 |
+
transition={{ duration: 0.15 }}
|
| 488 |
+
className="overflow-hidden"
|
| 489 |
+
>
|
| 490 |
+
<div className="px-3 py-2.5 text-[10px] leading-relaxed font-mono text-gray-400 max-h-48 overflow-y-auto">
|
| 491 |
+
{lines.map((line, i) => {
|
| 492 |
+
const isNew = gen > 0 && i >= PROMPTS[gen - 1].split('\n').length
|
| 493 |
+
return (
|
| 494 |
+
<div
|
| 495 |
+
key={i}
|
| 496 |
+
className={isNew ? 'text-green-400 bg-green-500/8 -mx-3 px-3' : ''}
|
| 497 |
+
>
|
| 498 |
+
{line || <br />}
|
| 499 |
+
</div>
|
| 500 |
+
)
|
| 501 |
+
})}
|
| 502 |
+
</div>
|
| 503 |
+
</motion.div>
|
| 504 |
+
)}
|
| 505 |
+
</AnimatePresence>
|
| 506 |
+
</div>
|
| 507 |
+
|
| 508 |
+
{/* Score history */}
|
| 509 |
+
<div className="flex flex-col gap-1">
|
| 510 |
+
<div className="text-[10px] text-gray-600 font-semibold uppercase tracking-wider mb-0.5">Evolution</div>
|
| 511 |
+
{SCORES.slice(0, gen + 1).map((s, i) => (
|
| 512 |
+
<div key={i} className="flex items-center gap-2">
|
| 513 |
+
<span className="text-[10px] text-gray-600 w-10 shrink-0">Gen {i}</span>
|
| 514 |
+
<div className="flex-1 h-1 bg-white/[0.05] rounded-full overflow-hidden">
|
| 515 |
+
<div
|
| 516 |
+
className="h-full rounded-full"
|
| 517 |
+
style={{
|
| 518 |
+
width: `${s * 100}%`,
|
| 519 |
+
background: i === gen ? 'linear-gradient(90deg,#8b5cf6,#22c55e)' : '#374151',
|
| 520 |
+
}}
|
| 521 |
+
/>
|
| 522 |
+
</div>
|
| 523 |
+
<span className={`text-[10px] font-bold tabular-nums ${i === gen ? 'text-green-400' : 'text-gray-600'}`}>
|
| 524 |
+
{(s * 100).toFixed(0)}%
|
| 525 |
+
</span>
|
| 526 |
+
</div>
|
| 527 |
+
))}
|
| 528 |
+
</div>
|
| 529 |
+
</div>
|
| 530 |
+
)
|
| 531 |
+
}
|
| 532 |
+
|
| 533 |
+
// βββ Main DemoMode Component ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 534 |
+
|
| 535 |
+
interface DemoModeProps {
|
| 536 |
+
onClose: () => void
|
| 537 |
+
}
|
| 538 |
+
|
| 539 |
+
export function DemoMode({ onClose }: DemoModeProps) {
|
| 540 |
+
const [bubbles, setBubbles] = useState<BubbleData[]>([])
|
| 541 |
+
const [appState, setAppState] = useState<AppState>('idle')
|
| 542 |
+
const [gen, setGen] = useState(0)
|
| 543 |
+
const [score, setScore] = useState(SCORES[0])
|
| 544 |
+
const cancel = useRef(false)
|
| 545 |
+
const bottomRef = useRef<HTMLDivElement>(null)
|
| 546 |
+
|
| 547 |
+
const push = useCallback((b: BubbleData) => {
|
| 548 |
+
setBubbles((prev) => [...prev, b])
|
| 549 |
+
}, [])
|
| 550 |
+
|
| 551 |
+
const replaceLast = useCallback((b: BubbleData) => {
|
| 552 |
+
setBubbles((prev) => [...prev.slice(0, -1), b])
|
| 553 |
+
}, [])
|
| 554 |
+
|
| 555 |
+
const scrollDown = useCallback(() => {
|
| 556 |
+
setTimeout(() => bottomRef.current?.scrollIntoView({ behavior: 'smooth' }), 50)
|
| 557 |
+
}, [])
|
| 558 |
+
|
| 559 |
+
// Type text character by character
|
| 560 |
+
const typeUser = useCallback(async (text: string) => {
|
| 561 |
+
const id = uid()
|
| 562 |
+
push({ id, type: 'user', text: '' })
|
| 563 |
+
for (let i = 1; i <= text.length; i++) {
|
| 564 |
+
if (cancel.current) return
|
| 565 |
+
setBubbles((prev) => prev.map((b) => (b.id === id ? { ...b, text: text.slice(0, i) } : b)))
|
| 566 |
+
await sleep(35 + Math.random() * 25)
|
| 567 |
+
}
|
| 568 |
+
scrollDown()
|
| 569 |
+
await sleep(350)
|
| 570 |
+
}, [push, scrollDown])
|
| 571 |
+
|
| 572 |
+
// Stream SQL line by line
|
| 573 |
+
const streamSQL = useCallback(async (sql: string, attempt: number) => {
|
| 574 |
+
const id = uid()
|
| 575 |
+
push({ id, type: 'sql_stream', sql: '', attempt })
|
| 576 |
+
let built = ''
|
| 577 |
+
for (const line of sql.split('\n')) {
|
| 578 |
+
if (cancel.current) return
|
| 579 |
+
built += (built ? '\n' : '') + line
|
| 580 |
+
setBubbles((prev) => prev.map((b) => (b.id === id ? { ...b, sql: built } : b)))
|
| 581 |
+
await sleep(90 + Math.random() * 80)
|
| 582 |
+
}
|
| 583 |
+
scrollDown()
|
| 584 |
+
await sleep(250)
|
| 585 |
+
// Remove stream bubble (will be replaced by err or ok bubble)
|
| 586 |
+
setBubbles((prev) => prev.filter((b) => b.id !== id))
|
| 587 |
+
}, [push, scrollDown])
|
| 588 |
+
|
| 589 |
+
// Play one full query (possibly multiple attempts)
|
| 590 |
+
const playQuery = useCallback(async (def: QueryDef): Promise<BubbleData[]> => {
|
| 591 |
+
const children: BubbleData[] = []
|
| 592 |
+
|
| 593 |
+
await typeUser(def.question)
|
| 594 |
+
|
| 595 |
+
for (let i = 0; i < def.attempts.length; i++) {
|
| 596 |
+
if (cancel.current) return children
|
| 597 |
+
const att = def.attempts[i]
|
| 598 |
+
const attemptNum = i + 1
|
| 599 |
+
|
| 600 |
+
// Show thinking
|
| 601 |
+
const thinkId = uid()
|
| 602 |
+
push({ id: thinkId, type: 'thinking', label: 'Generating SQLβ¦' })
|
| 603 |
+
scrollDown()
|
| 604 |
+
await sleep(800)
|
| 605 |
+
if (cancel.current) return children
|
| 606 |
+
setBubbles((prev) => prev.filter((b) => b.id !== thinkId))
|
| 607 |
+
|
| 608 |
+
await streamSQL(att.sql, attemptNum)
|
| 609 |
+
if (cancel.current) return children
|
| 610 |
+
|
| 611 |
+
if (att.error) {
|
| 612 |
+
const errBubble: SqlErrBubble = {
|
| 613 |
+
id: uid(), type: 'sql_err',
|
| 614 |
+
sql: att.sql, error: att.error,
|
| 615 |
+
errorClass: att.errorClass ?? 'OTHER',
|
| 616 |
+
rlAction: att.rlAction ?? 'REWRITE_FULL',
|
| 617 |
+
reward: att.reward,
|
| 618 |
+
attempt: attemptNum,
|
| 619 |
+
}
|
| 620 |
+
push(errBubble)
|
| 621 |
+
children.push(errBubble)
|
| 622 |
+
scrollDown()
|
| 623 |
+
await sleep(900)
|
| 624 |
+
} else {
|
| 625 |
+
const okBubble: SqlOkBubble = {
|
| 626 |
+
id: uid(), type: 'sql_ok',
|
| 627 |
+
sql: att.sql,
|
| 628 |
+
rows: att.rows ?? [],
|
| 629 |
+
reward: att.reward,
|
| 630 |
+
attempt: attemptNum,
|
| 631 |
+
badge: def.badge,
|
| 632 |
+
firstTry: attemptNum === 1,
|
| 633 |
+
}
|
| 634 |
+
push(okBubble)
|
| 635 |
+
children.push(okBubble)
|
| 636 |
+
scrollDown()
|
| 637 |
+
await sleep(1200)
|
| 638 |
+
}
|
| 639 |
+
}
|
| 640 |
+
|
| 641 |
+
return children
|
| 642 |
+
}, [push, scrollDown, typeUser, streamSQL])
|
| 643 |
+
|
| 644 |
+
// Collapse query into a group bubble
|
| 645 |
+
const collapseQuery = useCallback((def: QueryDef, children: BubbleData[]) => {
|
| 646 |
+
const lastAttempt = def.attempts[def.attempts.length - 1]
|
| 647 |
+
const success = !lastAttempt.error
|
| 648 |
+
|
| 649 |
+
// Remove user bubble + children, replace with group
|
| 650 |
+
setBubbles((prev) => {
|
| 651 |
+
// Find user bubble for this question
|
| 652 |
+
const userIdx = [...prev].reverse().findIndex(
|
| 653 |
+
(b) => b.type === 'user' && (b as UserBubble).text === def.question
|
| 654 |
+
)
|
| 655 |
+
if (userIdx < 0) return prev
|
| 656 |
+
const fromIdx = prev.length - 1 - userIdx
|
| 657 |
+
const userBubble = prev[fromIdx] as UserBubble
|
| 658 |
+
|
| 659 |
+
const group: GroupBubble = {
|
| 660 |
+
id: uid(),
|
| 661 |
+
type: 'group',
|
| 662 |
+
question: userBubble.text,
|
| 663 |
+
badge: def.badge,
|
| 664 |
+
difficulty: def.difficulty,
|
| 665 |
+
success,
|
| 666 |
+
attempts: def.attempts.length,
|
| 667 |
+
children,
|
| 668 |
+
}
|
| 669 |
+
return [...prev.slice(0, fromIdx), group]
|
| 670 |
+
})
|
| 671 |
+
}, [])
|
| 672 |
+
|
| 673 |
+
// Animate GEPA cycle
|
| 674 |
+
const playGepa = useCallback(async (fromGen: number, toGen: number) => {
|
| 675 |
+
setAppState('gepa')
|
| 676 |
+
|
| 677 |
+
const thinkId = uid()
|
| 678 |
+
const steps = [
|
| 679 |
+
'Analyzing failure patternsβ¦',
|
| 680 |
+
'Identifying missing rulesβ¦',
|
| 681 |
+
'Rewriting system promptβ¦',
|
| 682 |
+
'Benchmarking new promptβ¦',
|
| 683 |
+
]
|
| 684 |
+
for (const step of steps) {
|
| 685 |
+
if (cancel.current) return
|
| 686 |
+
replaceLast({ id: thinkId, type: 'thinking', label: step })
|
| 687 |
+
push({ id: thinkId, type: 'thinking', label: step })
|
| 688 |
+
setBubbles((prev) => {
|
| 689 |
+
const last = prev[prev.length - 1]
|
| 690 |
+
if (last?.type === 'thinking') return [...prev.slice(0, -1), { id: thinkId, type: 'thinking', label: step }]
|
| 691 |
+
return [...prev, { id: thinkId, type: 'thinking', label: step }]
|
| 692 |
+
})
|
| 693 |
+
scrollDown()
|
| 694 |
+
await sleep(1100)
|
| 695 |
+
}
|
| 696 |
+
// Remove thinking
|
| 697 |
+
setBubbles((prev) => prev.filter((b) => b.id !== thinkId))
|
| 698 |
+
|
| 699 |
+
// Animate score
|
| 700 |
+
const from = SCORES[fromGen]
|
| 701 |
+
const to = SCORES[toGen]
|
| 702 |
+
for (let i = 0; i <= 40; i++) {
|
| 703 |
+
if (cancel.current) return
|
| 704 |
+
setScore(from + (to - from) * (i / 40))
|
| 705 |
+
await sleep(20)
|
| 706 |
+
}
|
| 707 |
+
setGen(toGen)
|
| 708 |
+
|
| 709 |
+
// Push GEPA bubble
|
| 710 |
+
push({ id: uid(), type: 'gepa', fromGen, toGen, scoreFrom: from, scoreTo: to })
|
| 711 |
+
scrollDown()
|
| 712 |
+
await sleep(1000)
|
| 713 |
+
setAppState('running')
|
| 714 |
+
}, [push, replaceLast, scrollDown])
|
| 715 |
+
|
| 716 |
+
const autoPlay = useCallback(async () => {
|
| 717 |
+
cancel.current = false
|
| 718 |
+
setBubbles([])
|
| 719 |
+
setGen(0)
|
| 720 |
+
setScore(SCORES[0])
|
| 721 |
+
setAppState('running')
|
| 722 |
+
await sleep(400)
|
| 723 |
+
|
| 724 |
+
// Round 1
|
| 725 |
+
for (const id of ROUND_1) {
|
| 726 |
+
if (cancel.current) break
|
| 727 |
+
const def = QUERIES[id]
|
| 728 |
+
const children = await playQuery(def)
|
| 729 |
+
if (cancel.current) break
|
| 730 |
+
await sleep(400)
|
| 731 |
+
collapseQuery(def, children)
|
| 732 |
+
await sleep(700)
|
| 733 |
+
}
|
| 734 |
+
|
| 735 |
+
if (!cancel.current) {
|
| 736 |
+
await playGepa(0, 1)
|
| 737 |
+
await sleep(500)
|
| 738 |
+
}
|
| 739 |
+
|
| 740 |
+
// Round 2
|
| 741 |
+
for (const id of ROUND_2) {
|
| 742 |
+
if (cancel.current) break
|
| 743 |
+
const def = QUERIES[id]
|
| 744 |
+
const children = await playQuery(def)
|
| 745 |
+
if (cancel.current) break
|
| 746 |
+
await sleep(400)
|
| 747 |
+
collapseQuery(def, children)
|
| 748 |
+
await sleep(700)
|
| 749 |
+
}
|
| 750 |
+
|
| 751 |
+
if (!cancel.current) {
|
| 752 |
+
await playGepa(1, 2)
|
| 753 |
+
setAppState('done')
|
| 754 |
+
}
|
| 755 |
+
}, [playQuery, collapseQuery, playGepa])
|
| 756 |
+
|
| 757 |
+
const handleStart = () => void autoPlay()
|
| 758 |
+
|
| 759 |
+
const handleReplay = () => {
|
| 760 |
+
cancel.current = true
|
| 761 |
+
setTimeout(() => {
|
| 762 |
+
cancel.current = false
|
| 763 |
+
setBubbles([])
|
| 764 |
+
setGen(0)
|
| 765 |
+
setScore(SCORES[0])
|
| 766 |
+
setAppState('idle')
|
| 767 |
+
}, 200)
|
| 768 |
+
}
|
| 769 |
+
|
| 770 |
+
useEffect(() => {
|
| 771 |
+
return () => { cancel.current = true }
|
| 772 |
+
}, [])
|
| 773 |
+
|
| 774 |
+
return (
|
| 775 |
+
<motion.div
|
| 776 |
+
initial={{ opacity: 0 }}
|
| 777 |
+
animate={{ opacity: 1 }}
|
| 778 |
+
exit={{ opacity: 0 }}
|
| 779 |
+
className="fixed inset-0 z-[100] flex flex-col"
|
| 780 |
+
style={{ background: 'var(--bg-primary)' }}
|
| 781 |
+
>
|
| 782 |
+
{/* Header bar */}
|
| 783 |
+
<div
|
| 784 |
+
className="shrink-0 flex items-center justify-between px-4 py-3 border-b border-white/[0.06]"
|
| 785 |
+
style={{ background: 'var(--bg-secondary)' }}
|
| 786 |
+
>
|
| 787 |
+
<div className="flex items-center gap-3">
|
| 788 |
+
<div className="flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-violet-500/15 border border-violet-500/25">
|
| 789 |
+
<Play size={10} className="text-violet-400" fill="currentColor" />
|
| 790 |
+
<span className="text-[11px] font-semibold text-violet-300">Demo Mode</span>
|
| 791 |
+
</div>
|
| 792 |
+
<span className="text-xs text-gray-500 hidden sm:block">
|
| 793 |
+
Watching SQL Agent learn through RL repair loop + GEPA prompt evolution
|
| 794 |
+
</span>
|
| 795 |
+
</div>
|
| 796 |
+
<div className="flex items-center gap-2">
|
| 797 |
+
{appState !== 'idle' && (
|
| 798 |
+
<button
|
| 799 |
+
onClick={handleReplay}
|
| 800 |
+
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-white/[0.06] text-[11px] text-gray-400 hover:text-white hover:bg-white/5 transition-all"
|
| 801 |
+
>
|
| 802 |
+
<RotateCcw size={11} />
|
| 803 |
+
Replay
|
| 804 |
+
</button>
|
| 805 |
+
)}
|
| 806 |
+
<button
|
| 807 |
+
onClick={onClose}
|
| 808 |
+
className="p-1.5 rounded-lg hover:bg-white/5 transition-colors text-gray-500 hover:text-white"
|
| 809 |
+
>
|
| 810 |
+
<X size={16} />
|
| 811 |
+
</button>
|
| 812 |
+
</div>
|
| 813 |
+
</div>
|
| 814 |
+
|
| 815 |
+
{/* Body */}
|
| 816 |
+
<div className="flex flex-1 overflow-hidden">
|
| 817 |
+
{/* Chat column */}
|
| 818 |
+
<div className="flex-1 flex flex-col overflow-hidden">
|
| 819 |
+
<div className="flex-1 overflow-y-auto px-4 py-4">
|
| 820 |
+
{appState === 'idle' ? (
|
| 821 |
+
/* Start screen */
|
| 822 |
+
<div className="flex flex-col items-center justify-center h-full gap-6 text-center px-6">
|
| 823 |
+
<div
|
| 824 |
+
className="w-16 h-16 rounded-2xl flex items-center justify-center"
|
| 825 |
+
style={{ background: 'linear-gradient(135deg,#3b0764,#1e3a5f)', boxShadow: '0 12px 40px rgba(139,92,246,0.3)' }}
|
| 826 |
+
>
|
| 827 |
+
<Play size={26} className="text-white ml-0.5" fill="currentColor" />
|
| 828 |
+
</div>
|
| 829 |
+
<div>
|
| 830 |
+
<h2 className="text-lg font-bold text-white mb-2">SQL Agent in Action</h2>
|
| 831 |
+
<p className="text-sm text-gray-500 max-w-sm">
|
| 832 |
+
Watch the agent fail on ambiguous queries, use LinUCB to pick repair strategies,
|
| 833 |
+
and improve via GEPA prompt evolution β from 42% β 91% accuracy.
|
| 834 |
+
</p>
|
| 835 |
+
</div>
|
| 836 |
+
<div className="flex flex-col gap-2 text-xs text-gray-600 max-w-xs">
|
| 837 |
+
{[
|
| 838 |
+
'Round 1 β 3 queries with RL repair loop',
|
| 839 |
+
'GEPA cycle β prompt evolves Gen 0 β Gen 1',
|
| 840 |
+
'Round 2 β same queries succeed faster',
|
| 841 |
+
'GEPA cycle β Gen 1 β Gen 2 (91% accuracy)',
|
| 842 |
+
].map((s, i) => (
|
| 843 |
+
<div key={i} className="flex items-center gap-2">
|
| 844 |
+
<div className="w-4 h-4 rounded-full bg-violet-500/20 border border-violet-500/30 flex items-center justify-center text-[9px] text-violet-400 font-bold shrink-0">{i + 1}</div>
|
| 845 |
+
{s}
|
| 846 |
+
</div>
|
| 847 |
+
))}
|
| 848 |
+
</div>
|
| 849 |
+
<button
|
| 850 |
+
onClick={handleStart}
|
| 851 |
+
className="flex items-center gap-2 px-6 py-3 rounded-2xl font-semibold text-sm text-white transition-all active:scale-95"
|
| 852 |
+
style={{ background: 'linear-gradient(135deg,#7c3aed,#2563eb)', boxShadow: '0 8px 24px rgba(124,58,237,0.4)' }}
|
| 853 |
+
>
|
| 854 |
+
<Play size={14} fill="currentColor" />
|
| 855 |
+
Start Demo
|
| 856 |
+
</button>
|
| 857 |
+
</div>
|
| 858 |
+
) : (
|
| 859 |
+
<div className="flex flex-col gap-4 max-w-2xl mx-auto">
|
| 860 |
+
<AnimatePresence initial={false}>
|
| 861 |
+
{bubbles.map((b) => (
|
| 862 |
+
<motion.div
|
| 863 |
+
key={b.id}
|
| 864 |
+
initial={{ opacity: 0, y: 8 }}
|
| 865 |
+
animate={{ opacity: 1, y: 0 }}
|
| 866 |
+
transition={{ duration: 0.2 }}
|
| 867 |
+
>
|
| 868 |
+
<Bubble b={b} />
|
| 869 |
+
</motion.div>
|
| 870 |
+
))}
|
| 871 |
+
</AnimatePresence>
|
| 872 |
+
{appState === 'done' && (
|
| 873 |
+
<motion.div
|
| 874 |
+
initial={{ opacity: 0, y: 12 }}
|
| 875 |
+
animate={{ opacity: 1, y: 0 }}
|
| 876 |
+
className="border border-green-500/25 rounded-2xl p-4 bg-green-500/5 text-center"
|
| 877 |
+
>
|
| 878 |
+
<div className="text-2xl font-bold text-green-400 mb-1">91%</div>
|
| 879 |
+
<div className="text-sm text-white font-semibold mb-1">Demo complete</div>
|
| 880 |
+
<div className="text-xs text-gray-500">
|
| 881 |
+
Agent improved from 42% β 91% through RL repair + GEPA evolution
|
| 882 |
+
</div>
|
| 883 |
+
<button
|
| 884 |
+
onClick={handleReplay}
|
| 885 |
+
className="mt-3 flex items-center gap-1.5 px-4 py-2 rounded-xl border border-white/[0.06] text-xs text-gray-400 hover:text-white hover:bg-white/5 transition-all mx-auto"
|
| 886 |
+
>
|
| 887 |
+
<RotateCcw size={11} />
|
| 888 |
+
Watch again
|
| 889 |
+
</button>
|
| 890 |
+
</motion.div>
|
| 891 |
+
)}
|
| 892 |
+
<div ref={bottomRef} />
|
| 893 |
+
</div>
|
| 894 |
+
)}
|
| 895 |
+
</div>
|
| 896 |
+
</div>
|
| 897 |
+
|
| 898 |
+
{/* Right panel β prompt evolution */}
|
| 899 |
+
<aside
|
| 900 |
+
className="hidden lg:flex flex-col w-72 border-l border-white/[0.06] overflow-y-auto shrink-0"
|
| 901 |
+
style={{ background: 'var(--bg-secondary)' }}
|
| 902 |
+
>
|
| 903 |
+
<PromptPanel gen={gen} score={score} />
|
| 904 |
+
</aside>
|
| 905 |
+
</div>
|
| 906 |
+
</motion.div>
|
| 907 |
+
)
|
| 908 |
+
}
|
frontend/src/components/Header.tsx
CHANGED
|
@@ -1,10 +1,11 @@
|
|
| 1 |
-
import { Database, Sun, Moon, PanelLeftOpen, PanelRightOpen, Cpu } from 'lucide-react'
|
| 2 |
import { useStore } from '../store/useStore'
|
| 3 |
import type { Difficulty } from '../lib/types'
|
| 4 |
|
| 5 |
interface HeaderProps {
|
| 6 |
onToggleLeft: () => void
|
| 7 |
onToggleRight: () => void
|
|
|
|
| 8 |
}
|
| 9 |
|
| 10 |
const DIFFICULTIES: { id: Difficulty; label: string; color: string }[] = [
|
|
@@ -13,7 +14,7 @@ const DIFFICULTIES: { id: Difficulty; label: string; color: string }[] = [
|
|
| 13 |
{ id: 'hard', label: 'Hard', color: 'text-red-400 border-red-500/30 bg-red-500/10' },
|
| 14 |
]
|
| 15 |
|
| 16 |
-
export function Header({ onToggleLeft, onToggleRight }: HeaderProps) {
|
| 17 |
const { theme, toggleTheme, dbSeeded, taskDifficulty, setTaskDifficulty } = useStore()
|
| 18 |
|
| 19 |
return (
|
|
@@ -87,6 +88,16 @@ export function Header({ onToggleLeft, onToggleRight }: HeaderProps) {
|
|
| 87 |
))}
|
| 88 |
</div>
|
| 89 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
{/* Theme toggle */}
|
| 91 |
<button
|
| 92 |
onClick={toggleTheme}
|
|
|
|
| 1 |
+
import { Database, Sun, Moon, PanelLeftOpen, PanelRightOpen, Cpu, Play } from 'lucide-react'
|
| 2 |
import { useStore } from '../store/useStore'
|
| 3 |
import type { Difficulty } from '../lib/types'
|
| 4 |
|
| 5 |
interface HeaderProps {
|
| 6 |
onToggleLeft: () => void
|
| 7 |
onToggleRight: () => void
|
| 8 |
+
onDemo: () => void
|
| 9 |
}
|
| 10 |
|
| 11 |
const DIFFICULTIES: { id: Difficulty; label: string; color: string }[] = [
|
|
|
|
| 14 |
{ id: 'hard', label: 'Hard', color: 'text-red-400 border-red-500/30 bg-red-500/10' },
|
| 15 |
]
|
| 16 |
|
| 17 |
+
export function Header({ onToggleLeft, onToggleRight, onDemo }: HeaderProps) {
|
| 18 |
const { theme, toggleTheme, dbSeeded, taskDifficulty, setTaskDifficulty } = useStore()
|
| 19 |
|
| 20 |
return (
|
|
|
|
| 88 |
))}
|
| 89 |
</div>
|
| 90 |
|
| 91 |
+
{/* Demo button */}
|
| 92 |
+
<button
|
| 93 |
+
onClick={onDemo}
|
| 94 |
+
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[11px] font-semibold text-white transition-all active:scale-95"
|
| 95 |
+
style={{ background: 'linear-gradient(135deg,#7c3aed,#2563eb)', boxShadow: '0 4px 12px rgba(124,58,237,0.35)' }}
|
| 96 |
+
>
|
| 97 |
+
<Play size={10} fill="currentColor" />
|
| 98 |
+
<span>Demo</span>
|
| 99 |
+
</button>
|
| 100 |
+
|
| 101 |
{/* Theme toggle */}
|
| 102 |
<button
|
| 103 |
onClick={toggleTheme}
|