|
1 | | -import { Suspense, lazy, useEffect, useMemo, useState, type FormEvent } from 'react' |
| 1 | +import { useEffect, useState, type FormEvent } from 'react' |
2 | 2 | import './App.css' |
3 | | -import { fetchReport } from './api' |
| 3 | +import { fetchPlaybook, runAgents } from './api' |
4 | 4 | import { AgentColumn } from './components/AgentColumn' |
5 | | -import { AlertBanner, MemoryPanel } from './components/MemoryPanel' |
6 | | -import type { Report, ReportCase } from './types' |
| 5 | +import { MemoryPanel } from './components/MemoryPanel' |
| 6 | +import type { PlaybookEntry, RunResponse } from './types' |
7 | 7 |
|
8 | | -// Recharts is heavy — lazy-load the chart so it stays out of the main bundle. |
9 | | -const LearningCurve = lazy(() => |
10 | | - import('./components/LearningCurve').then((m) => ({ default: m.LearningCurve })), |
11 | | -) |
| 8 | +type View = 'audit' | 'memory' |
| 9 | +type RunState = |
| 10 | + | { status: 'idle' } |
| 11 | + | { status: 'running' } |
| 12 | + | { status: 'done'; result: RunResponse } |
| 13 | + | { status: 'error'; message: string } |
12 | 14 |
|
13 | | -type View = 'compare' | 'learning' |
14 | | -type Load = { status: 'loading' } | { status: 'ok'; report: Report } | { status: 'error'; message: string } |
| 15 | +function AuditPage() { |
| 16 | + const [url, setUrl] = useState('') |
| 17 | + const [keyword, setKeyword] = useState('') |
| 18 | + const [run, setRun] = useState<RunState>({ status: 'idle' }) |
15 | 19 |
|
16 | | -// The case where B most beats A on coverage (so the win shows first), else the first. |
17 | | -function winningCase(report: Report): ReportCase | undefined { |
18 | | - const sorted = [...report.cases].sort( |
19 | | - (x, y) => |
20 | | - y.b.scores.coverage - y.a.scores.coverage - (x.b.scores.coverage - x.a.scores.coverage), |
21 | | - ) |
22 | | - return sorted[0] ?? report.cases[0] |
23 | | -} |
24 | | - |
25 | | -function AggregateSummary({ report }: { report: Report }) { |
26 | | - const a = report.aggregate.A |
27 | | - const b = report.aggregate.B |
28 | | - const bWins = b.coverage > a.coverage |
29 | | - const metrics: Array<[string, number, number]> = [ |
30 | | - ['coverage', a.coverage, b.coverage], |
31 | | - ['correctness', a.correctness, b.correctness], |
32 | | - ['novelty', a.novelty, b.novelty], |
33 | | - ['efficiency', a.efficiency, b.efficiency], |
34 | | - ] |
35 | | - return ( |
36 | | - <div className="summary"> |
37 | | - <div className="summary-head"> |
38 | | - Overall (held-out eval): {bWins ? 'Agent B beats Agent A' : 'A vs B'} |
39 | | - </div> |
40 | | - <table className="summary-table"> |
41 | | - <thead> |
42 | | - <tr> |
43 | | - <th>metric</th> |
44 | | - <th>A</th> |
45 | | - <th>B</th> |
46 | | - </tr> |
47 | | - </thead> |
48 | | - <tbody> |
49 | | - {metrics.map(([name, av, bv]) => ( |
50 | | - <tr key={name}> |
51 | | - <td>{name}</td> |
52 | | - <td>{av.toFixed(2)}</td> |
53 | | - <td className={bv > av ? 'win' : ''}>{bv.toFixed(2)}</td> |
54 | | - </tr> |
55 | | - ))} |
56 | | - </tbody> |
57 | | - </table> |
58 | | - </div> |
59 | | - ) |
60 | | -} |
61 | | - |
62 | | -function ComparePage({ report }: { report: Report }) { |
63 | | - const initial = winningCase(report) |
64 | | - const [url, setUrl] = useState(initial?.url ?? '') |
65 | | - const [keyword, setKeyword] = useState(initial?.keyword ?? '') |
66 | | - const [selectedId, setSelectedId] = useState(initial?.id ?? '') |
67 | | - const [running, setRunning] = useState(false) |
68 | | - const [notFound, setNotFound] = useState(false) |
69 | | - |
70 | | - const selected: ReportCase | undefined = useMemo( |
71 | | - () => report.cases.find((c) => c.id === selectedId), |
72 | | - [report, selectedId], |
73 | | - ) |
74 | | - |
75 | | - function handleSubmit(event: FormEvent<HTMLFormElement>) { |
| 20 | + async function handleSubmit(event: FormEvent<HTMLFormElement>) { |
76 | 21 | event.preventDefault() |
77 | | - const match = report.cases.find( |
78 | | - (c) => c.keyword.toLowerCase() === keyword.trim().toLowerCase() || c.url === url.trim(), |
79 | | - ) |
80 | | - if (!match) { |
81 | | - setNotFound(true) |
82 | | - return |
| 22 | + if (!url.trim() || !keyword.trim()) return |
| 23 | + setRun({ status: 'running' }) |
| 24 | + try { |
| 25 | + const result = await runAgents(url.trim(), keyword.trim()) |
| 26 | + setRun({ status: 'done', result }) |
| 27 | + } catch (error) { |
| 28 | + setRun({ status: 'error', message: error instanceof Error ? error.message : 'unknown error' }) |
83 | 29 | } |
84 | | - setNotFound(false) |
85 | | - setRunning(true) |
86 | | - setSelectedId(match.id) |
87 | | - // brief spinner to convey "running A and B" |
88 | | - window.setTimeout(() => setRunning(false), 350) |
89 | 30 | } |
90 | 31 |
|
91 | 32 | return ( |
92 | 33 | <> |
93 | 34 | <form className="run-form" onSubmit={handleSubmit}> |
94 | 35 | <input |
95 | | - type="text" |
96 | | - placeholder="Page URL" |
| 36 | + type="url" |
| 37 | + placeholder="Real page URL (e.g. https://example.com/blog/post)" |
97 | 38 | value={url} |
98 | 39 | onChange={(e) => setUrl(e.target.value)} |
99 | | - list="known-urls" |
| 40 | + required |
100 | 41 | /> |
101 | 42 | <input |
102 | 43 | type="text" |
103 | 44 | placeholder="Target keyword" |
104 | 45 | value={keyword} |
105 | 46 | onChange={(e) => setKeyword(e.target.value)} |
106 | | - list="known-keywords" |
| 47 | + required |
107 | 48 | /> |
108 | | - <button type="submit">Run A vs B</button> |
109 | | - <datalist id="known-urls"> |
110 | | - {report.cases.map((c) => ( |
111 | | - <option key={c.id} value={c.url} /> |
112 | | - ))} |
113 | | - </datalist> |
114 | | - <datalist id="known-keywords"> |
115 | | - {report.cases.map((c) => ( |
116 | | - <option key={c.id} value={c.keyword} /> |
117 | | - ))} |
118 | | - </datalist> |
| 49 | + <button type="submit" disabled={run.status === 'running'}> |
| 50 | + {run.status === 'running' ? 'Running…' : 'Run A vs B (live)'} |
| 51 | + </button> |
119 | 52 | </form> |
120 | | - {notFound && ( |
121 | | - <p className="hint"> |
122 | | - Recorded demo cases only — pick a keyword/URL from the suggestions (live runs need an API key). |
123 | | - </p> |
124 | | - )} |
| 53 | + <p className="hint"> |
| 54 | + Live run on the real page — fetches it, pulls the live SERP, and runs both agents. Takes |
| 55 | + ~1–2 minutes. |
| 56 | + </p> |
125 | 57 |
|
126 | | - <AggregateSummary report={report} /> |
| 58 | + {run.status === 'error' && <p className="hint error">{run.message}</p>} |
127 | 59 |
|
128 | | - {running || !selected ? ( |
| 60 | + {run.status === 'running' && ( |
129 | 61 | <div className="split"> |
130 | | - <div className="agent-column spinner-box">running Agent A…</div> |
| 62 | + <div className="agent-column spinner-box">running Agent A on the live page…</div> |
131 | 63 | <div className="agent-column spinner-box">running Agent B…</div> |
132 | 64 | </div> |
133 | | - ) : ( |
| 65 | + )} |
| 66 | + |
| 67 | + {run.status === 'done' && ( |
134 | 68 | <main className="split"> |
135 | | - <AgentColumn agent="A" view={selected.a} /> |
136 | | - <AgentColumn agent="B" view={selected.b} /> |
| 69 | + <AgentColumn view={run.result.a} /> |
| 70 | + <AgentColumn view={run.result.b} /> |
137 | 71 | </main> |
138 | 72 | )} |
139 | 73 | </> |
140 | 74 | ) |
141 | 75 | } |
142 | 76 |
|
143 | | -export default function App() { |
144 | | - const [view, setView] = useState<View>('compare') |
145 | | - const [load, setLoad] = useState<Load>({ status: 'loading' }) |
| 77 | +function MemoryPage() { |
| 78 | + const [playbook, setPlaybook] = useState<PlaybookEntry[] | null>(null) |
| 79 | + const [error, setError] = useState<string | null>(null) |
146 | 80 |
|
147 | 81 | useEffect(() => { |
148 | 82 | let active = true |
149 | | - fetchReport() |
150 | | - .then((report) => active && setLoad({ status: 'ok', report })) |
151 | | - .catch((error: unknown) => { |
152 | | - const message = error instanceof Error ? error.message : 'unknown error' |
153 | | - if (active) setLoad({ status: 'error', message }) |
154 | | - }) |
| 83 | + fetchPlaybook() |
| 84 | + .then((p) => active && setPlaybook(p)) |
| 85 | + .catch((e: unknown) => active && setError(e instanceof Error ? e.message : 'error')) |
155 | 86 | return () => { |
156 | 87 | active = false |
157 | 88 | } |
158 | 89 | }, []) |
159 | 90 |
|
| 91 | + if (error) return <p className="hint error">{error}</p> |
| 92 | + if (!playbook) return <p className="hint">loading playbook…</p> |
| 93 | + return ( |
| 94 | + <main className="learning"> |
| 95 | + <MemoryPanel playbook={playbook} /> |
| 96 | + </main> |
| 97 | + ) |
| 98 | +} |
| 99 | + |
| 100 | +export default function App() { |
| 101 | + const [view, setView] = useState<View>('audit') |
160 | 102 | return ( |
161 | 103 | <div className="app"> |
162 | 104 | <header className="app-header"> |
163 | 105 | <h1>Learning SEO Agent</h1> |
164 | 106 | <nav className="nav"> |
165 | | - <button className={view === 'compare' ? 'active' : ''} onClick={() => setView('compare')}> |
166 | | - Compare |
| 107 | + <button className={view === 'audit' ? 'active' : ''} onClick={() => setView('audit')}> |
| 108 | + Audit (live) |
167 | 109 | </button> |
168 | | - <button className={view === 'learning' ? 'active' : ''} onClick={() => setView('learning')}> |
169 | | - Learning & Memory |
| 110 | + <button className={view === 'memory' ? 'active' : ''} onClick={() => setView('memory')}> |
| 111 | + B's Memory |
170 | 112 | </button> |
171 | 113 | </nav> |
172 | 114 | </header> |
173 | | - |
174 | | - {load.status === 'loading' && <p className="hint">Running the agents… (first load replays the comparison)</p>} |
175 | | - {load.status === 'error' && <p className="hint error">Could not load report: {load.message}</p>} |
176 | | - {load.status === 'ok' && view === 'compare' && <ComparePage report={load.report} />} |
177 | | - {load.status === 'ok' && view === 'learning' && ( |
178 | | - <main className="learning"> |
179 | | - <AlertBanner alert={load.report.alert} /> |
180 | | - <Suspense fallback={<p className="hint">loading chart…</p>}> |
181 | | - <LearningCurve cases={load.report.cases} /> |
182 | | - </Suspense> |
183 | | - <MemoryPanel playbook={load.report.playbook} /> |
184 | | - </main> |
185 | | - )} |
| 115 | + {view === 'audit' ? <AuditPage /> : <MemoryPage />} |
186 | 116 | </div> |
187 | 117 | ) |
188 | 118 | } |
0 commit comments