import { useState, useRef, useEffect } from 'react' import ReactMarkdown from 'react-markdown' import remarkGfm from 'remark-gfm' const URL_REGEX = /^(https?:\/\/)?([\w-]+\.)+[\w-]{2,}(\/[\w\-._~:/?#[\]@!$&'()*+,;=%]*)?$/i const initialForm = { userName: '', userEmail: '', stationName: '', stationLocation: '', stationWebsite: '', } export default function App() { const [form, setForm] = useState(initialForm) const [errors, setErrors] = useState({}) const [isSubmitting, setIsSubmitting] = useState(false) const [isThinking, setIsThinking] = useState(false) const [isStreaming, setIsStreaming] = useState(false) const [result, setResult] = useState('') const [status, setStatus] = useState(null) const [streamError, setStreamError] = useState(null) const resultRef = useRef(null) useEffect(() => { if (resultRef.current) { resultRef.current.scrollTop = resultRef.current.scrollHeight } }, [result]) const handleChange = (e) => { const { name, value } = e.target setForm((prev) => ({ ...prev, [name]: value })) } const validate = () => { const next = {} if (!form.userName.trim()) next.userName = 'Required' if (!form.userEmail.trim() || !/^\S+@\S+\.\S+$/.test(form.userEmail)) next.userEmail = 'Valid email required' if (!form.stationName.trim()) next.stationName = 'Required' if (!form.stationLocation.trim()) next.stationLocation = 'Required' if (!form.stationWebsite.trim() || !URL_REGEX.test(form.stationWebsite)) next.stationWebsite = 'Valid URL required' setErrors(next) return Object.keys(next).length === 0 } // Parse the NDJSON stream from /api/agent-proxy: one JSON object per // line. Returns a resume payload `{sessionId, lastEventId, startedAt}` // if the server emitted a `segment_end` (Netlify Edge caps a single // streaming response at ~60 s, so longer briefs span multiple // segments). Returns null when the stream ends cleanly (`done`, // `error`, or upstream EOF without a segment_end). const readStream = async (response) => { const reader = response.body.getReader() const decoder = new TextDecoder('utf-8') let buffer = '' let firstByte = true let segmentEnd = null let fatalError = null const handleEvent = (msg) => { switch (msg?.type) { case 'text': if (typeof msg.text === 'string' && msg.text) { setResult((prev) => prev + msg.text) } break case 'status': setStatus(msg) break case 'segment_end': if (typeof msg.sessionId === 'string' && msg.sessionId) { segmentEnd = { sessionId: msg.sessionId, lastEventId: msg.lastEventId, startedAt: msg.startedAt, } } break case 'error': case 'session_error': fatalError = msg.message || 'streaming error' setStreamError(fatalError) break case 'done': case 'heartbeat': default: break } } const flushLines = () => { let nl while ((nl = buffer.indexOf('\n')) !== -1) { const line = buffer.slice(0, nl).trim() buffer = buffer.slice(nl + 1) if (!line) continue try { handleEvent(JSON.parse(line)) } catch { // Ignore malformed lines — the heartbeat / done sentinel // will still tell us when the stream is over. } } } while (true) { const { done, value } = await reader.read() if (done) break buffer += decoder.decode(value, { stream: true }) if (firstByte) { // Once any bytes arrive, flip 'thinking' off and 'streaming' on. setIsThinking(false) setIsStreaming(true) firstByte = false } flushLines() } // Flush any trailing buffered bytes (line without final \n). buffer += decoder.decode() if (buffer.trim()) { try { handleEvent(JSON.parse(buffer.trim())) } catch { /* ignore */ } } // Only return a resume payload if we got a clean segment_end AND // no fatal error fired in the same segment. return fatalError ? null : segmentEnd } const handleSubmit = async (e) => { e.preventDefault() if (!validate()) return setResult('') setStatus(null) setStreamError(null) setIsSubmitting(true) setIsThinking(true) setIsStreaming(false) try { let resume = null // Loop across segments. Each iteration is one HTTP request; // the server closes it at ~54 s and the next iteration picks // up from `lastEventId` so the user sees one continuous brief. // Hard cap on iterations as a belt-and-braces guard against a // runaway segment loop. for (let i = 0; i < 40; i++) { const body = resume ? { sessionId: resume.sessionId, lastEventId: resume.lastEventId, startedAt: resume.startedAt, } : form const response = await fetch('/api/agent-proxy', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }) if (!response.ok || !response.body) { throw new Error( `Request failed: ${response.status} ${response.statusText}`, ) } const next = await readStream(response) if (!next) break resume = next } } catch (err) { console.error(err) setStreamError(err.message || 'Something went wrong while streaming.') } finally { setIsSubmitting(false) setIsThinking(false) setIsStreaming(false) setStatus(null) } } return (
Tell us about your public media station and we'll have an Anthropic-powered agent draft a tailored funding outlook.