8e44de5271
Netlify Edge Functions empirically cap a single streaming response at
~60s wall-clock, regardless of activity. Confirmed against Netlify's
own canonical SSE example (edge-functions-examples.netlify.app/sse)
which also cuts at +60.1s. The Anthropic Managed Agent session is
fine for several minutes; the cap is per-HTTP-response.
This commit splits a long brief across multiple HTTP responses, while
keeping the UX of one continuous stream:
netlify/edge-functions/agent-proxy.ts
- Accept either a new-session payload {stationName, stationLocation,
stationWebsite} or a resume payload {sessionId, lastEventId,
startedAt}.
- On resume, skip session creation + user.message send. Just reopen
the live SSE stream and backfill via
GET /v1/sessions/{id}/events?after_id=lastEventId (deduped by
event.id), then keep tailing.
- Single AbortController per segment. A 54s timer aborts the upstream,
the for-await loops exit, and we write one final NDJSON line:
{type:'segment_end', sessionId, lastEventId, startedAt}.
- The 20-min OVERALL_BUDGET_MS is enforced via Date.now() - startedAt
so it spans across all segments.
- Refactor main loop so every iteration is openStream + backfill +
tail. Cleaner than the previous initial-stream + reconnect-only-on-
drop pattern.
src/App.jsx
- readStream() now returns a {sessionId, lastEventId, startedAt}
payload if it saw a segment_end, or null if the stream ended
cleanly.
- handleSubmit() loops, reopening /api/agent-proxy with the resume
payload until readStream returns null. Spinner/status state stays
on across segments so the UI shows one continuous stream.
README.md
- Document the segmented-streaming protocol and why it exists.
Co-Authored-By: alex <alex@semipublic.co>
370 lines
11 KiB
React
370 lines
11 KiB
React
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 (
|
|
<div className="min-h-screen w-full font-serif">
|
|
<div className="mx-auto max-w-3xl px-6 py-12">
|
|
<header className="mb-10">
|
|
<h1 className="text-4xl font-semibold tracking-tight">
|
|
PMC Funder Discovery Tool
|
|
</h1>
|
|
<p className="mt-2 text-stone-600">
|
|
Tell us about your public media station and we'll have an
|
|
Anthropic-powered agent draft a tailored funding outlook.
|
|
</p>
|
|
</header>
|
|
|
|
<form
|
|
onSubmit={handleSubmit}
|
|
className="space-y-5 rounded-xl border border-stone-300 bg-white/70 p-6 shadow-sm"
|
|
>
|
|
<Field
|
|
label="Your Name"
|
|
name="userName"
|
|
value={form.userName}
|
|
onChange={handleChange}
|
|
error={errors.userName}
|
|
/>
|
|
<Field
|
|
label="Your Email"
|
|
name="userEmail"
|
|
type="email"
|
|
value={form.userEmail}
|
|
onChange={handleChange}
|
|
error={errors.userEmail}
|
|
/>
|
|
<Field
|
|
label="Station Name"
|
|
name="stationName"
|
|
value={form.stationName}
|
|
onChange={handleChange}
|
|
error={errors.stationName}
|
|
/>
|
|
<Field
|
|
label="Station Location"
|
|
name="stationLocation"
|
|
value={form.stationLocation}
|
|
onChange={handleChange}
|
|
error={errors.stationLocation}
|
|
/>
|
|
<Field
|
|
label="Station Website"
|
|
name="stationWebsite"
|
|
type="url"
|
|
placeholder="https://example.org"
|
|
value={form.stationWebsite}
|
|
onChange={handleChange}
|
|
error={errors.stationWebsite}
|
|
/>
|
|
|
|
<button
|
|
type="submit"
|
|
disabled={isSubmitting}
|
|
className="w-full rounded-md bg-stone-900 px-4 py-3 text-white transition hover:bg-stone-700 disabled:cursor-not-allowed disabled:bg-stone-400"
|
|
>
|
|
{isSubmitting ? 'Working…' : 'Generate Funding Brief'}
|
|
</button>
|
|
</form>
|
|
|
|
<section className="mt-10">
|
|
{isSubmitting && !isStreaming && (
|
|
<StatusBanner>
|
|
{isThinking ? (
|
|
<>
|
|
<Spinner /> Claude is thinking…
|
|
</>
|
|
) : (
|
|
<>
|
|
<Spinner /> Connecting to Agent…
|
|
</>
|
|
)}
|
|
</StatusBanner>
|
|
)}
|
|
|
|
{isStreaming && status && (
|
|
<StatusBanner>
|
|
<Spinner /> {renderStatus(status)}
|
|
</StatusBanner>
|
|
)}
|
|
|
|
{streamError && (
|
|
<div className="rounded-md border border-red-300 bg-red-50 p-4 text-red-800">
|
|
{streamError}
|
|
</div>
|
|
)}
|
|
|
|
{(result || isStreaming) && (
|
|
<article
|
|
ref={resultRef}
|
|
className="markdown max-h-[60vh] overflow-y-auto rounded-xl border border-stone-300 bg-white p-6 leading-relaxed shadow-sm"
|
|
>
|
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
|
{result}
|
|
</ReactMarkdown>
|
|
{isStreaming && <span className="cursor-pulse" />}
|
|
</article>
|
|
)}
|
|
</section>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function Field({ label, name, type = 'text', value, onChange, error, placeholder }) {
|
|
return (
|
|
<label className="block">
|
|
<span className="mb-1 block text-sm font-medium text-stone-800">
|
|
{label}
|
|
</span>
|
|
<input
|
|
type={type}
|
|
name={name}
|
|
value={value}
|
|
onChange={onChange}
|
|
placeholder={placeholder}
|
|
className={`w-full rounded-md border bg-white px-3 py-2 outline-none transition focus:ring-2 focus:ring-stone-700 ${
|
|
error ? 'border-red-400' : 'border-stone-300'
|
|
}`}
|
|
/>
|
|
{error && <span className="mt-1 block text-sm text-red-600">{error}</span>}
|
|
</label>
|
|
)
|
|
}
|
|
|
|
function renderStatus(status) {
|
|
if (!status) return null
|
|
switch (status.kind) {
|
|
case 'thinking':
|
|
return 'Thinking…'
|
|
case 'tool_use':
|
|
return status.label || `Using tool: ${status.name || 'tool'}`
|
|
case 'tool_result':
|
|
if (status.ok) return 'Tool finished — continuing…'
|
|
return `Tool error: ${status.message || 'unknown error'}`
|
|
case 'session_error':
|
|
return `Session error: ${status.message || 'unknown error'}`
|
|
default:
|
|
return 'Working…'
|
|
}
|
|
}
|
|
|
|
function StatusBanner({ children }) {
|
|
return (
|
|
<div className="mb-4 flex items-center gap-3 rounded-md border border-stone-300 bg-stone-100 px-4 py-3 text-stone-700">
|
|
{children}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function Spinner() {
|
|
return (
|
|
<svg
|
|
className="h-4 w-4 animate-spin text-stone-700"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
>
|
|
<circle
|
|
className="opacity-25"
|
|
cx="12"
|
|
cy="12"
|
|
r="10"
|
|
stroke="currentColor"
|
|
strokeWidth="4"
|
|
/>
|
|
<path
|
|
className="opacity-75"
|
|
fill="currentColor"
|
|
d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"
|
|
/>
|
|
</svg>
|
|
)
|
|
}
|