Compare commits

...

16 Commits

Author SHA1 Message Date
alex a565502989 Insert row 2026-05-29 09:24:16 -04:00
alex bce0763785 Merge pull request #5 from Semipublic/devin/1779300013-docx-formatting
feat: apply Noto Serif font and heading styles to DOCX export
2026-05-20 15:34:35 -04:00
Devin AI 7ecb6b8962 feat: apply Noto Serif font and heading styles to DOCX export
Add document-level styles so the downloaded .docx has:
- Noto Serif font throughout (matching the web UI)
- Sized headings: H1 20pt, H2 16pt, H3 14pt, H4 12pt
- 11pt body text with 1.15× line spacing
- Consistent color (#1f1b16) and spacing
- Styled hyperlinks (blue, underlined)
- List paragraph font inheritance

Co-Authored-By: alex <alex@semipublic.co>
2026-05-20 18:00:13 +00:00
alex 337b3a42c0 Merge pull request #4 from Semipublic/devin/1778863829-max-height-1000px
feat: change output container max-height to 1000px
2026-05-15 12:54:23 -04:00
Devin AI 44fdacd248 feat: change output container max-height to 1000px
Co-Authored-By: alex <alex@semipublic.co>
2026-05-15 16:50:29 +00:00
alex 871ac48ab2 Merge pull request #3 from Semipublic/devin/1778860013-double-output-height
feat: double the height of the output container
2026-05-15 11:59:37 -04:00
Devin AI d0d021562a feat: double the height of the output container from 60vh to 120vh
Co-Authored-By: alex <alex@semipublic.co>
2026-05-15 15:46:53 +00:00
alex 40ecd1b758 Merge pull request #2 from Semipublic/devin/1778851718-download-response
feat: add Copy Text and Download .docx buttons to response window
2026-05-15 10:19:00 -04:00
Devin AI 94d4ba90d3 feat: add Copy Text and Download .docx buttons to response window
- Copy Text: copies raw markdown to clipboard with visual feedback
- Download .docx: converts markdown to a properly formatted Word
  document using the docx library (headings, bold/italic, lists,
  tables, blockquotes, links, horizontal rules)
- Both buttons appear below the response article after streaming ends
- docx/file-saver are lazy-loaded (dynamic import) so they don't
  bloat the initial bundle (~308 KB main vs ~407 KB docx chunk)

Co-Authored-By: alex <alex@semipublic.co>
2026-05-15 13:30:38 +00:00
alex d8e8fb1d3f Merge pull request #1 from Semipublic/devin/1778642010-fix-stream-reconnect
fix(agent-proxy): Edge Function + reconnect/backfill + segmented streaming so long sessions stream end-to-end
2026-05-13 09:34:52 -04:00
Devin AI ed0069ed89 fix(agent-proxy): cancel upstream reader on abort so segment timer can actually interrupt parseSse
Root cause of "segment_end never written, response just runs until
Netlify kills it": the Fetch spec only honors AbortSignal for the
request/header phase. Once you have `res.body` and start reading
from it, aborting that same signal does NOT close the body stream —
any in-flight `reader.read()` hangs indefinitely.

So when the segment timer fired and called segmentAbort.abort(), the
running `for await (const event of parseSse(upstream))` stayed
blocked on a `reader.read()` that never resolved. The while loop
never exited, segment_end was never written, controller.close() was
never called, and Netlify eventually pulled the rug somewhere
between ~49 s and ~60 s in.

Fix: parseSse now takes an optional AbortSignal and, when it fires,
calls `reader.cancel()` directly. The WHATWG ReadableStream spec
guarantees that any pending read() resolves with `{done: true}`
after cancel, which lets the for-await exit promptly and the body
loop re-evaluate `while (!done && !segmenting)` → break →
writeJson(segment_end) → controller.close(). Confirmed in the curl
trace: heartbeats now stop at the budget mark and segment_end fires
before Netlify's cap.

Co-Authored-By: alex <alex@semipublic.co>
2026-05-13 13:20:19 +00:00
Devin AI 25c4e2442c fix(agent-proxy): conservative segment budget + drop unnecessary upstream.cancel()
Two related fixes after re-testing on the deploy preview:

1. SEGMENT_BUDGET_MS: 54_000 -> 40_000.

   Empirically, Netlify Edge Function responses get cut anywhere
   between ~49 s and ~60 s wall-clock — significantly more variance
   than the docs imply. With a 54 s budget, our segment_end write +
   controller.close() sometimes lands AFTER the platform has already
   killed the response (curl: 'HTTP/2 stream 1 was not closed cleanly
   before end of the underlying stream'). 40 s gives ~10 s of headroom
   on the low end of the observed range while still keeping segment
   overhead reasonable (~1 segment per 40 s of brief).

2. HEARTBEAT_MS: 10_000 -> 5_000.

   Tighter keep-alive so the connection stays warm even during long
   model-thinking gaps where the agent isn't emitting events.

3. Drop the 'await upstream.cancel()' between backfill and tail.

   The upstream fetch's body is already cleaned up by
   segmentAbort.abort() (or will be by the runtime once we drop our
   reference). On Deno Edge, awaiting cancel() on an already-aborted
   body can hang, which prevents segment_end from being written.

Co-Authored-By: alex <alex@semipublic.co>
2026-05-13 13:16:44 +00:00
Devin AI df3c8e7d1c fix(agent-proxy): events.list uses opaque page cursor, not after_id
The Managed Agents events endpoint (`GET /v1/sessions/{id}/events`)
does NOT support filtering by event id. It returns an opaque
`next_page` cursor on each response and accepts it back via the
`page` query parameter; an `after_id=` filter returns 400 Bad
Request, which caused every segment resume to fail backfill (visible
as `{"type":"status","kind":"session_error","message":"Backfill
failed: events.list returned 400 Bad Request"}`).

Caught during testing of commit 8e44de5: resuming from a segment
boundary always returned 400 and the brief silently lost events from
the previous segment.

Changes:
- `listAllEvents` now paginates via `page` / `next_page` and pulls
  the full session history (limit=1000). The Anthropic API has no
  per-id filter, so the caller is responsible for skipping events
  already delivered.
- New `pastInitialId` flag at the top of the body loop: on resume,
  mute every event up to and including `initialLastEventId`
  (still adding them to `seenEventIds` so the live stream doesn't
  re-emit them), then start delivering. On a brand-new session the
  flag starts true and is a no-op.
- Safety fallback: if backfill completes without ever seeing
  `initialLastEventId` (stale cursor / truncated history), flip
  the flag to true so we don't get stuck muting forever — the live
  stream will start delivering whatever shows up next.

Co-Authored-By: alex <alex@semipublic.co>
2026-05-13 13:08:54 +00:00
Devin AI 8e44de5271 feat(agent-proxy): segment streaming responses at ~54s to bypass Netlify Edge's ~60s response cap
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>
2026-05-13 13:00:54 +00:00
Devin AI d1c5be112e migrate agent-proxy to Netlify Edge Function so long sessions stream end-to-end
The reconnect + events.list backfill in c283d88 is correct but never ran:
the previous v2 Node Function was killed at ~27 s (well before the 20 min
reconnect budget could matter), so streams always died after the first MCP
tool batch.

Move the proxy to a Netlify Edge Function (Deno runtime) which has no
streaming-duration cap as long as we keep writing to the response body.
Same reconnect / backfill / dedupe-by-event-id pattern; same NDJSON wire
protocol to the browser. Implemented with plain fetch() against the
Anthropic REST API (npm packages on Edge are beta) so we have no SDK
runtime dependency.

Frontend now POSTs to /api/agent-proxy. The Anthropic SDK is removed
from the package; @netlify/edge-functions is added for ambient types.

Co-Authored-By: alex <alex@semipublic.co>
2026-05-13 12:40:31 +00:00
Devin AI c283d884cf fix(agent-proxy): reconnect with events.list backfill so long sessions don't cut off
The Anthropic Managed-Agents SSE stream (`/v1/sessions/{id}/events/stream`)
is not guaranteed to stay open for the life of a multi-turn session.
Inter-turn gaps (e.g. while MCP tools or the next model request run) of
~25s+ are common and can trigger upstream / proxy read timeouts; when
that happens, the SDK's `for await` iterator just ends and the proxy
silently closed the downstream response, leaving the browser with only
the events from the first tool batch.

Implement the official 'consolidation' pattern: treat any upstream
end-of-stream that isn't a terminal `session.status_*` as a transient
drop, fetch what we missed via `sessions.events.list()` (deduped by
event.id), reopen the live stream, and keep going — up to a 20 minute
wall-clock budget so a wedged session can't pin the function forever.

Also switch the wire format from inline-markdown-with-status-comments to
NDJSON, so the React side can render in-flight tool activity in a
separate status banner from the streaming brief. The existing zero-width
heartbeat is replaced by an explicit `{"type":"heartbeat"}` line.

Drop the `agent.tool_use` write-tool special case — the agent now
streams the brief directly via `agent.message` text blocks.

Co-Authored-By: alex <alex@semipublic.co>
2026-05-13 03:20:15 +00:00
9 changed files with 3385 additions and 412 deletions
+47 -12
View File
@@ -2,7 +2,7 @@
A React + Vite frontend (Tailwind, Noto Serif) that collects public media
station details and streams an Anthropic Managed Agent response back to the
browser through a Netlify Functions v2 proxy.
browser through a Netlify Edge Function proxy.
## Setup
@@ -31,17 +31,52 @@ Open http://localhost:8888.
## How streaming works
1. The browser POSTs the form to `/.netlify/functions/agent-proxy`.
2. The Netlify v2 function calls `anthropic.messages.stream(...)` and wraps
the upstream iterator in a `ReadableStream`. Each
`content_block_delta` text chunk is enqueued as plain UTF8 bytes.
3. The React app reads `response.body.getReader()` and decodes chunks with
`TextDecoder`, appending them to the result state.
4. A `Thinking` flag stays `true` until the first chunk arrives, then flips
to a streaming state with a pulsing cursor.
1. The browser POSTs the form to `/api/agent-proxy`.
2. A Netlify Edge Function (Deno runtime) creates an Anthropic Managed
Agent session, opens the upstream SSE event stream, and sends the
user message. It tails the stream and re-opens it (with an
`events.list` backfill, deduped by event id) whenever it drops mid
session, until the session reports a terminal `session.status_*`
event or a 20-minute wall-clock budget is hit.
3. Downstream to the browser the function emits newline-delimited JSON
(`application/x-ndjson`) — `text`, `status`, `heartbeat`,
`segment_end`, `done`, `error` — one object per line.
4. The React app reads `response.body.getReader()`, splits on `\n`, and
parses each line. `text` lines append to the brief; `status` lines
drive a separate “what the agent is doing now” banner.
5. A `Thinking` flag stays `true` until the first chunk arrives, then
flips to a streaming state with a pulsing cursor.
### Why segmented streaming
Netlify Edge Functions empirically cap a single streaming response at
~60 s wall-clock (Netlify's own canonical SSE example cuts at the same
mark, even though the docs imply "indefinite" streaming). The Anthropic
Managed Agent session itself is fine for several minutes; the limit is
per-HTTP-response. So the proxy segments the stream:
- Just before ~54 s the function writes one final NDJSON line —
`{"type":"segment_end","sessionId":"sess_…","lastEventId":"evt_…","startedAt":…}`
and closes the response.
- The React side sees `segment_end`, immediately POSTs to
`/api/agent-proxy` again with `{sessionId, lastEventId, startedAt}`
in the body.
- The function recognises this as a resume payload: it does **not**
create a new session or re-send the user message. It just reopens
the live SSE stream, backfills via
`GET /v1/sessions/{id}/events?after_id=lastEventId` (deduped by
`event.id`), and keeps tailing.
The 20-minute overall budget is enforced via `Date.now() - startedAt`
so it spans across all segments. The previous v2 Node Function (on AWS
Lambda) was killed at ~27 s, well before any reconnect could fire —
moving to Edge Functions extended that to ~60 s per response, and the
segment loop extends it the rest of the way.
## Files
- [src/App.jsx](src/App.jsx) — form, streaming reader, UI states.
- [netlify/functions/agent-proxy.js](netlify/functions/agent-proxy.js) —
Managed Agent proxy with `ReadableStream`.
- [src/App.jsx](src/App.jsx) — form, NDJSON streaming reader,
segment-end resume loop, UI states.
- [netlify/edge-functions/agent-proxy.ts](netlify/edge-functions/agent-proxy.ts)
— Managed Agent proxy with reconnect, backfill, segmentation, and
NDJSON wire protocol.
-4
View File
@@ -1,10 +1,6 @@
[build]
command = "npm run build"
publish = "dist"
functions = "netlify/functions"
[functions]
node_bundler = "esbuild"
[dev]
framework = "vite"
+766
View File
@@ -0,0 +1,766 @@
import type { Config, Context } from '@netlify/edge-functions'
/**
* Netlify Edge Function (Deno runtime).
*
* Proxies a request from the React frontend to a Claude Console-defined
* Managed Agent. The agent's model, system prompt, tools, MCP servers
* and skills are configured in the Console and referenced here by ID.
*
* ## Why this exists
*
* Two layered problems are in scope:
*
* 1. The upstream Anthropic SSE stream
* (`GET /v1/sessions/{id}/events/stream`) is not guaranteed to stay
* open for the full life of a multi-turn session; inter-turn gaps
* can trigger upstream / proxy read timeouts. We reconnect on drop
* and backfill missed events via `GET /v1/sessions/{id}/events`,
* deduped by `event.id`.
*
* 2. Netlify Edge Functions cap a single streaming response at ~60 s
* wall-clock (empirically confirmed against Netlify's own canonical
* SSE example). The Anthropic session itself is fine for ~minutes;
* the limit is per-HTTP-response. So we segment the stream: after
* ~54 s the function emits a `segment_end` line with `{sessionId,
* lastEventId, startedAt}` and closes the response. The React side
* immediately reopens against this same endpoint with that resume
* payload, and we pick up from `lastEventId` via backfill. To the
* user this looks like one continuous stream.
*
* ## Wire protocol (downstream to the browser): NDJSON
*
* {"type":"text","text":"...markdown..."} // append to brief
* {"type":"status","kind":"thinking"}
* {"type":"status","kind":"tool_use","name":"...","label":"..."}
* {"type":"status","kind":"tool_result","ok":true}
* {"type":"status","kind":"tool_result","ok":false,"message":"..."}
* {"type":"status","kind":"session_error","message":"..."}
* {"type":"heartbeat"} // keep-alive
* {"type":"segment_end","sessionId":"sess_...",
* "lastEventId":"evt_...","startedAt":1234567890} // reopen me
* {"type":"done"} // terminal
* {"type":"error","message":"..."} // unrecoverable
*
* ## Request payloads
*
* - New session (first call):
* {stationName, stationLocation, stationWebsite}
* - Resume (subsequent calls after a segment_end):
* {sessionId, lastEventId?, startedAt}
*
* The 20-min OVERALL budget spans across segments and is gated by
* `Date.now() - startedAt`. The SEGMENT budget is per-HTTP-response
* and exists only to keep us comfortably under Netlify's 60 s cap.
*/
const MANAGED_AGENTS_BETA = 'managed-agents-2026-04-01'
const ANTHROPIC_VERSION = '2023-06-01'
const ANTHROPIC_API_BASE = 'https://api.anthropic.com'
// Downstream keep-alive cadence. The browser's fetch reader appears
// stuck if no bytes flow for too long. We keep this tighter than the
// segment budget so the connection stays warm even during long
// model-thinking gaps with no agent events.
const HEARTBEAT_MS = 5_000
// Close the current response well before Netlify's empirical streaming
// cap. We've observed responses cut anywhere between ~49 s and ~60 s,
// so a 54 s budget is too aggressive — sometimes the platform pulls
// the rug out before our final `segment_end` write + `controller.close()`
// can flush. 40 s gives ~10 s of headroom on the low end of the
// observed range while still amortising session setup (~1 segment per
// ~40 s of brief) across a typical 3-5 min brief.
const SEGMENT_BUDGET_MS = 40_000
// Total wall-clock allowance across ALL segments for a single user
// brief. Past this we emit a final `error` line and stop reopening.
const OVERALL_BUDGET_MS = 20 * 60 * 1000 // 20 minutes
// Short backoff between an upstream drop and the next reconnect.
const RECONNECT_BACKOFF_MS = 500
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type ContentBlock = { type?: string; text?: string }
type AgentEvent = {
id?: string
type?: string
content?: ContentBlock[]
name?: string
input?: Record<string, unknown>
is_error?: boolean
stop_reason?: { type?: string }
error?: { message?: string }
}
type EventsListPage = {
data?: AgentEvent[]
next_page?: string
}
type NewSessionPayload = {
stationName?: string
stationLocation?: string
stationWebsite?: string
}
type ResumePayload = {
sessionId: string
lastEventId?: string
startedAt?: number
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function authHeaders(apiKey: string): Record<string, string> {
return {
'x-api-key': apiKey,
'anthropic-version': ANTHROPIC_VERSION,
'anthropic-beta': MANAGED_AGENTS_BETA,
}
}
// A `session.status_idle` with `stop_reason: requires_action` means
// the agent is waiting on a client-side response (custom tool result,
// tool confirmation). For this app we don't expose custom tools, so
// it shouldn't fire, but if it ever does we explicitly do NOT treat
// it as terminal — let the loop keep tailing.
function isTerminal(event: AgentEvent | undefined): boolean {
if (!event) return false
if (event.type === 'session.status_terminated') return true
if (event.type === 'session.status_idle') {
const t = event.stop_reason?.type
return t === 'end_turn' || t === 'retries_exhausted'
}
return false
}
function describeToolUse(event: AgentEvent): string {
const name = event.name || 'tool'
const input = (event.input || {}) as Record<string, unknown>
if (name === 'web_search' && typeof input.query === 'string') {
return `Searching the web: ${input.query}`
}
if (name === 'web_fetch' && typeof input.url === 'string') {
return `Fetching: ${input.url}`
}
if (event.type === 'agent.mcp_tool_use') {
const args = Object.entries(input)
.filter(([k]) => k !== 'limit')
.slice(0, 3)
.map(([k, v]) =>
typeof v === 'object' && v !== null
? `${k}=…`
: `${k}=${String(v).slice(0, 40)}`,
)
.join(', ')
return `${name}${args ? ` (${args})` : ''}`
}
return name
}
async function anthropicFetch(
path: string,
apiKey: string,
init: RequestInit = {},
): Promise<Response> {
const headers = {
...authHeaders(apiKey),
...(init.headers as Record<string, string> | undefined),
}
return await fetch(`${ANTHROPIC_API_BASE}${path}`, { ...init, headers })
}
// Parse the SSE stream into an async iterable of decoded events.
// Each SSE message is `event: <type>\ndata: <json>\n\n`.
//
// IMPORTANT: per the Fetch spec, an `AbortSignal` passed to `fetch()`
// only aborts the request/header phase — once `res.body` is returned,
// aborting that signal does NOT close the body stream and any
// in-flight `reader.read()` will hang forever. So we attach our own
// listener and `reader.cancel()` directly when the caller's signal
// aborts; the WHATWG spec guarantees pending reads resolve with
// `{done: true}` after cancel.
async function* parseSse(
stream: ReadableStream<Uint8Array>,
signal?: AbortSignal,
): AsyncGenerator<AgentEvent> {
const reader = stream.getReader()
const decoder = new TextDecoder('utf-8')
let buffer = ''
const onAbort = () => {
reader.cancel().catch(() => {})
}
if (signal) {
if (signal.aborted) onAbort()
else signal.addEventListener('abort', onAbort, { once: true })
}
try {
while (true) {
const { value, done } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
let sep
while ((sep = buffer.indexOf('\n\n')) !== -1) {
const raw = buffer.slice(0, sep)
buffer = buffer.slice(sep + 2)
let dataPayload = ''
let evType: string | undefined
for (const line of raw.split('\n')) {
if (line.startsWith('event:')) {
evType = line.slice(6).trim()
} else if (line.startsWith('data:')) {
dataPayload += line.slice(5).trim()
}
}
if (!dataPayload) continue
try {
const parsed = JSON.parse(dataPayload) as AgentEvent
if (evType && !parsed.type) parsed.type = evType
yield parsed
} catch {
// Malformed event — skip.
}
}
}
} finally {
if (signal) {
signal.removeEventListener('abort', onAbort)
}
try {
reader.releaseLock()
} catch {
/* ignore */
}
}
}
// Page through `GET /v1/sessions/{id}/events` to backfill anything
// we missed. The Managed Agents endpoint uses an opaque `page` cursor
// returned as `next_page` on each response; there is no `after_id`
// filter, so we always pull the full history and the caller is
// responsible for skipping events it has already handled (via
// `seenEventIds` and the resume-payload `lastEventId`).
async function* listAllEvents(
sessionId: string,
apiKey: string,
signal?: AbortSignal,
): AsyncGenerator<AgentEvent> {
let pageCursor: string | undefined
for (;;) {
const params = new URLSearchParams({ order: 'asc', limit: '1000' })
if (pageCursor) params.set('page', pageCursor)
const res = await anthropicFetch(
`/v1/sessions/${sessionId}/events?${params.toString()}`,
apiKey,
{ signal },
)
if (!res.ok) {
throw new Error(
`events.list returned ${res.status} ${res.statusText}`,
)
}
const page = (await res.json()) as EventsListPage
const items = page.data || []
for (const ev of items) yield ev
if (!page.next_page) break
pageCursor = page.next_page
}
}
async function openStream(
sessionId: string,
apiKey: string,
signal: AbortSignal,
): Promise<ReadableStream<Uint8Array>> {
const res = await anthropicFetch(
`/v1/sessions/${sessionId}/events/stream`,
apiKey,
{
method: 'GET',
headers: { accept: 'text/event-stream' },
signal,
},
)
if (!res.ok || !res.body) {
throw new Error(
`events.stream returned ${res.status} ${res.statusText}`,
)
}
return res.body
}
// `setTimeout` that resolves early on abort.
function abortableSleep(ms: number, signal: AbortSignal): Promise<void> {
return new Promise<void>((resolve) => {
const t = setTimeout(resolve, ms)
const onAbort = () => {
clearTimeout(t)
resolve()
}
if (signal.aborted) {
onAbort()
return
}
signal.addEventListener('abort', onAbort, { once: true })
})
}
// ---------------------------------------------------------------------------
// Handler
// ---------------------------------------------------------------------------
const URL_REGEX =
/^(https?:\/\/)?([\w-]+\.)+[\w-]{2,}(\/[\w\-._~:/?#[\]@!$&'()*+,;=%]*)?$/i
export default async (req: Request, _context: Context) => {
if (req.method !== 'POST') {
return new Response('Method Not Allowed', { status: 405 })
}
let payload: NewSessionPayload & Partial<ResumePayload>
try {
payload = await req.json()
} catch {
return new Response('Invalid JSON body', { status: 400 })
}
const apiKey = Netlify.env.get('ANTHROPIC_API_KEY')
if (!apiKey) {
return new Response('Server is missing ANTHROPIC_API_KEY', {
status: 500,
})
}
const isResume =
typeof payload.sessionId === 'string' && payload.sessionId.length > 0
let sessionId: string
let startedAt: number
let initialLastEventId: string | undefined
let userMessage: string | undefined
if (isResume) {
sessionId = payload.sessionId as string
startedAt =
typeof payload.startedAt === 'number' ? payload.startedAt : Date.now()
initialLastEventId =
typeof payload.lastEventId === 'string' && payload.lastEventId
? payload.lastEventId
: undefined
if (Date.now() - startedAt > OVERALL_BUDGET_MS) {
return new Response(
`Session ${sessionId} exceeded the ${Math.round(
OVERALL_BUDGET_MS / 60_000,
)}-minute overall budget; please start a new brief.`,
{ status: 408 },
)
}
} else {
const {
stationName = '',
stationLocation = '',
stationWebsite = '',
} = payload
if (!URL_REGEX.test(stationWebsite)) {
return new Response('Invalid station website URL', { status: 400 })
}
const AGENT_ID = Netlify.env.get('ANTHROPIC_AGENT_ID')
const ENVIRONMENT_ID = Netlify.env.get('ANTHROPIC_ENVIRONMENT_ID')
const VAULT_IDS = (Netlify.env.get('ANTHROPIC_VAULT_IDS') || '')
.split(',')
.map((s) => s.trim())
.filter(Boolean)
const missing: string[] = []
if (!AGENT_ID) missing.push('ANTHROPIC_AGENT_ID')
if (!ENVIRONMENT_ID) missing.push('ANTHROPIC_ENVIRONMENT_ID')
if (missing.length) {
return new Response(
`Server is missing env var(s): ${missing.join(', ')}`,
{ status: 500 },
)
}
userMessage = `Here is a new public media station intake:
- Station Name: ${stationName}
- Station Location: ${stationLocation}
- Station Website: ${stationWebsite}
Please follow your instructions to produce the funding outlook brief.`
const createBody: Record<string, unknown> = {
agent: AGENT_ID,
environment_id: ENVIRONMENT_ID,
}
if (VAULT_IDS.length) createBody.vault_ids = VAULT_IDS
try {
const createRes = await anthropicFetch('/v1/sessions', apiKey, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(createBody),
})
if (!createRes.ok) {
const text = await createRes.text()
return new Response(
`Failed to create session: ${createRes.status} ${text}`,
{ status: 500 },
)
}
const created = (await createRes.json()) as { id?: string }
if (!created.id) {
return new Response('Failed to create session: no id returned', {
status: 500,
})
}
sessionId = created.id
startedAt = Date.now()
} catch (err) {
return new Response(
`Failed to create session: ${(err as Error)?.message || err}`,
{ status: 500 },
)
}
}
const encoder = new TextEncoder()
const seenEventIds = new Set<string>()
const body = new ReadableStream<Uint8Array>({
async start(controller) {
let lastSendAt = Date.now()
let lastEventIdSeen = initialLastEventId
// For resumes we replay the full session history via
// `listAllEvents` but mute every event up to and including
// `initialLastEventId` (the user has already seen those). Once
// we've cleared that cursor (or on a brand-new session where it
// is undefined), we start actually delivering events.
let pastInitialId = !initialLastEventId
const writeJson = (obj: unknown) => {
try {
controller.enqueue(encoder.encode(JSON.stringify(obj) + '\n'))
lastSendAt = Date.now()
} catch {
/* controller closed */
}
}
const heartbeat = setInterval(() => {
if (Date.now() - lastSendAt >= HEARTBEAT_MS) {
writeJson({ type: 'heartbeat' })
}
}, HEARTBEAT_MS / 2)
// Single AbortController for this segment. The segment timer
// calls .abort() when we approach Netlify's wall-clock cap; the
// for-await loops exit promptly and we write `segment_end`.
const segmentAbort = new AbortController()
let segmenting = false
const segmentTimer = setTimeout(() => {
segmenting = true
try {
segmentAbort.abort()
} catch {
/* ignore */
}
}, SEGMENT_BUDGET_MS)
const handle = (event: AgentEvent) => {
switch (event.type) {
case 'agent.message': {
if (Array.isArray(event.content)) {
for (const block of event.content) {
if (block?.type === 'text' && block.text) {
writeJson({ type: 'text', text: block.text + '\n\n' })
}
}
}
break
}
case 'agent.thinking': {
writeJson({ type: 'status', kind: 'thinking' })
break
}
case 'agent.tool_use':
case 'agent.mcp_tool_use': {
writeJson({
type: 'status',
kind: 'tool_use',
name: event.name || 'tool',
label: describeToolUse(event),
})
break
}
case 'agent.tool_result':
case 'agent.mcp_tool_result': {
if (event.is_error) {
const msg =
(Array.isArray(event.content) && event.content[0]?.text) ||
'tool error'
writeJson({
type: 'status',
kind: 'tool_result',
ok: false,
message: String(msg).slice(0, 300),
})
} else {
writeJson({ type: 'status', kind: 'tool_result', ok: true })
}
break
}
case 'session.error': {
const msg = event.error?.message || 'unknown session error'
writeJson({
type: 'status',
kind: 'session_error',
message: msg,
})
break
}
}
}
// Brand new session: send the kickoff user.message BEFORE we
// start tailing. (For resume, the user.message was sent on the
// first segment, so we never re-send it.)
if (!isResume && userMessage) {
try {
const sendRes = await anthropicFetch(
`/v1/sessions/${sessionId}/events`,
apiKey,
{
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
events: [
{
type: 'user.message',
content: [{ type: 'text', text: userMessage }],
},
],
}),
},
)
if (!sendRes.ok) {
const text = await sendRes.text()
writeJson({
type: 'error',
message: `Failed to send user message: ${sendRes.status} ${text}`,
})
clearTimeout(segmentTimer)
clearInterval(heartbeat)
try {
controller.close()
} catch {
/* ignore */
}
return
}
} catch (err) {
writeJson({
type: 'error',
message: `Failed to send user message: ${(err as Error)?.message || err}`,
})
clearTimeout(segmentTimer)
clearInterval(heartbeat)
try {
controller.close()
} catch {
/* ignore */
}
return
}
}
let done = false
let iteration = 0
try {
while (!done && !segmenting) {
iteration++
if (iteration > 1) {
await abortableSleep(RECONNECT_BACKOFF_MS, segmentAbort.signal)
if (segmenting) break
}
if (Date.now() - startedAt >= OVERALL_BUDGET_MS) {
writeJson({
type: 'error',
message: `Stream budget (${Math.round(
OVERALL_BUDGET_MS / 60_000,
)} min) exhausted. The session may still be running — try again or check the Console.`,
})
break
}
// Open the live stream FIRST so any events emitted while
// we're backfilling are still captured on the wire.
let upstream: ReadableStream<Uint8Array>
try {
upstream = await openStream(
sessionId,
apiKey,
segmentAbort.signal,
)
} catch (err) {
if (segmenting) break
writeJson({
type: 'error',
message: `Failed to open event stream: ${(err as Error)?.message || err}`,
})
break
}
// Backfill anything we missed. The events endpoint does
// NOT support `after_id`, so we always pull the full session
// history and skip everything up to and including
// `initialLastEventId` (the cursor handed to us by the
// previous segment via the resume payload). Within this
// segment, `seenEventIds` dedupes against the live stream.
try {
for await (const event of listAllEvents(
sessionId,
apiKey,
segmentAbort.signal,
)) {
if (segmenting) break
if (event.id) {
if (!pastInitialId) {
// Already delivered to the user in a previous
// segment — mark seen so the live stream doesn't
// re-emit it, but do NOT call handle().
seenEventIds.add(event.id)
lastEventIdSeen = event.id
if (event.id === initialLastEventId) {
pastInitialId = true
}
} else if (!seenEventIds.has(event.id)) {
seenEventIds.add(event.id)
lastEventIdSeen = event.id
handle(event)
} else {
// Already handled earlier in this segment — keep
// the cursor moving forward.
lastEventIdSeen = event.id
}
}
if (isTerminal(event) && pastInitialId) {
done = true
break
}
}
} catch (err) {
if (!segmenting) {
writeJson({
type: 'status',
kind: 'session_error',
message: `Backfill failed: ${(err as Error)?.message || err}`,
})
// Fall through and try the live stream.
}
}
// If the cursor handed to us by the previous segment didn't
// appear in the session's history (stale / corrupted /
// history truncated), don't get stuck muting events forever
// — start delivering whatever shows up next.
if (!pastInitialId) {
pastInitialId = true
}
if (done || segmenting) {
// The upstream fetch is already cleaned up by
// `segmentAbort.abort()` (or will be by the runtime once
// we drop our reference). Don't `await upstream.cancel()`
// here — on an already-aborted body that call can hang
// on Deno Edge, which would prevent us from writing the
// final `segment_end` line.
break
}
// Tail the live stream until upstream EOFs (drop), the
// segment timer fires, or a terminal event arrives.
try {
for await (const event of parseSse(
upstream,
segmentAbort.signal,
)) {
if (segmenting) break
if (event.id) {
if (!seenEventIds.has(event.id)) {
seenEventIds.add(event.id)
lastEventIdSeen = event.id
handle(event)
} else {
lastEventIdSeen = event.id
}
}
if (isTerminal(event)) {
done = true
break
}
}
} catch {
// Most commonly: AbortError from segmentAbort. Treated
// as a soft drop — next loop iteration will reopen and
// backfill (or exit cleanly because `segmenting` is set).
}
}
if (done) {
writeJson({ type: 'done' })
} else if (segmenting) {
writeJson({
type: 'segment_end',
sessionId,
lastEventId: lastEventIdSeen,
startedAt,
})
}
} catch (err) {
writeJson({
type: 'error',
message: (err as Error)?.message || String(err),
})
} finally {
clearTimeout(segmentTimer)
clearInterval(heartbeat)
try {
controller.close()
} catch {
/* ignore */
}
}
},
})
return new Response(body, {
status: 200,
headers: {
'Content-Type': 'application/x-ndjson; charset=utf-8',
'Cache-Control': 'no-cache, no-transform',
'X-Accel-Buffering': 'no',
},
})
}
export const config: Config = {
path: '/api/agent-proxy',
}
-298
View File
@@ -1,298 +0,0 @@
import Anthropic from '@anthropic-ai/sdk'
/**
* Netlify Functions v2 handler.
*
* Proxies a request from the React frontend to a Claude Console-defined
* Managed Agent via the /v1/sessions endpoints. The agent's model, system
* prompt, tools, MCP servers, and skills are configured in the Console and
* referenced here by ID. We open the SSE event stream first, then send a
* `user.message` event, and pipe each `agent.message` text block to the
* client through a ReadableStream so we never hit Netlify's 26-second
* synchronous execution limit.
*/
// All Managed Agents endpoints require this beta header.
const MANAGED_AGENTS_BETA = 'managed-agents-2026-04-01'
// ---------------------------------------------------------------------------
// Anthropic client (reused across warm Lambda invocations)
// ---------------------------------------------------------------------------
let _client
function getClient() {
if (_client) return _client
if (!process.env.ANTHROPIC_API_KEY) {
throw new Error('Server is missing ANTHROPIC_API_KEY')
}
_client = new Anthropic({
apiKey: process.env.ANTHROPIC_API_KEY,
defaultHeaders: { 'anthropic-beta': MANAGED_AGENTS_BETA },
})
return _client
}
// ---------------------------------------------------------------------------
// Handler
// ---------------------------------------------------------------------------
export default async (req /*, context */) => {
if (req.method !== 'POST') {
return new Response('Method Not Allowed', { status: 405 })
}
let payload
try {
payload = await req.json()
} catch {
return new Response('Invalid JSON body', { status: 400 })
}
const {
stationName = '',
stationLocation = '',
stationWebsite = '',
} = payload || {}
// Server-side URL validation (mirrors the client-side regex).
const URL_REGEX =
/^(https?:\/\/)?([\w-]+\.)+[\w-]{2,}(\/[\w\-._~:/?#[\]@!$&'()*+,;=%]*)?$/i
if (!URL_REGEX.test(stationWebsite)) {
return new Response('Invalid station website URL', { status: 400 })
}
// Read env vars at request time (Netlify makes them available per-invocation).
const AGENT_ID = process.env.ANTHROPIC_AGENT_ID
const ENVIRONMENT_ID = process.env.ANTHROPIC_ENVIRONMENT_ID
const VAULT_IDS = (process.env.ANTHROPIC_VAULT_IDS || '')
.split(',')
.map((s) => s.trim())
.filter(Boolean)
const missing = []
if (!AGENT_ID) missing.push('ANTHROPIC_AGENT_ID')
if (!ENVIRONMENT_ID) missing.push('ANTHROPIC_ENVIRONMENT_ID')
if (missing.length) {
return new Response(`Server is missing env var(s): ${missing.join(', ')}`, {
status: 500,
})
}
let client
try {
client = getClient()
} catch (err) {
return new Response(
`Failed to initialize agent: ${err.message || err}`,
{ status: 500 },
)
}
const userMessage = `Here is a new public media station intake:
- Station Name: ${stationName}
- Station Location: ${stationLocation}
- Station Website: ${stationWebsite}
Please follow your instructions to produce the funding outlook brief.`
// ----- Create the session -----
let session
try {
const sessionParams = {
agent: AGENT_ID,
environment_id: ENVIRONMENT_ID,
}
if (VAULT_IDS.length) sessionParams.vault_ids = VAULT_IDS
session = await client.beta.sessions.create(sessionParams)
} catch (err) {
return new Response(
`Failed to create session: ${err.message || err}`,
{ status: 500 },
)
}
// ----- Open the event stream BEFORE sending the user message -----
// (Stream-first ensures we don't miss any events the agent emits.)
let upstream
try {
upstream = await client.beta.sessions.events.stream(session.id)
} catch (err) {
return new Response(
`Failed to open session event stream: ${err.message || err}`,
{ status: 500 },
)
}
// ----- Send the kickoff user.message event -----
try {
await client.beta.sessions.events.send(session.id, {
events: [
{
type: 'user.message',
content: [{ type: 'text', text: userMessage }],
},
],
})
} catch (err) {
return new Response(
`Failed to send user message: ${err.message || err}`,
{ status: 500 },
)
}
// ----- Pipe agent activity to the client -----
// Long stretches between agent.message events (model "thinking",
// multi-tool batches, etc.) can silence the response stream long enough
// that Netlify's edge proxy drops the connection. To keep bytes flowing
// we (a) forward extra event types as concise status lines and (b) emit
// a periodic heartbeat space when the upstream goes quiet.
const encoder = new TextEncoder()
const seenEventIds = new Set()
const writtenFiles = new Set() // dedupe writes by path
const HEARTBEAT_MS = 10_000
const stream = new ReadableStream({
async start(controller) {
let lastSendAt = Date.now()
const send = (s) => {
controller.enqueue(encoder.encode(s))
lastSendAt = Date.now()
}
// Zero-width space — invisible in rendered Markdown but counts as a
// byte on the wire, which is enough to defeat proxy idle-timeouts.
const heartbeat = setInterval(() => {
if (Date.now() - lastSendAt >= HEARTBEAT_MS) {
try {
controller.enqueue(encoder.encode('\u200B'))
lastSendAt = Date.now()
} catch {
/* controller may have closed */
}
}
}, HEARTBEAT_MS / 2)
try {
for await (const event of upstream) {
if (event.id && !seenEventIds.has(event.id)) {
seenEventIds.add(event.id)
switch (event.type) {
case 'agent.message': {
if (Array.isArray(event.content)) {
for (const block of event.content) {
if (block?.type === 'text' && block.text) {
send(block.text + '\n\n')
}
}
}
break
}
case 'agent.thinking': {
send(`\n\n_💭 thinking…_\n\n`)
break
}
case 'agent.tool_use': {
// The write tool carries the actual brief in input.content.
if (
event.name === 'write' &&
typeof event.input?.content === 'string'
) {
const path = event.input.file_path || event.input.path || ''
if (!writtenFiles.has(path)) {
writtenFiles.add(path)
send('\n\n---\n\n' + event.input.content + '\n\n')
}
} else {
const label = describeToolUse(event)
if (label) send(`\n\n_${label}_\n\n`)
}
break
}
case 'agent.mcp_tool_use': {
const label = describeToolUse(event)
if (label) send(`\n\n_${label}_\n\n`)
break
}
case 'agent.tool_result':
case 'agent.mcp_tool_result': {
if (event.is_error) {
const msg =
(Array.isArray(event.content) &&
event.content[0]?.text) ||
'tool error'
send(`\n\n_⚠️ tool error: ${String(msg).slice(0, 200)}_\n\n`)
} else {
send(`\n\n_✓ result_\n\n`)
}
break
}
case 'session.error': {
const msg = event.error?.message || 'unknown session error'
send(`\n\n[session error: ${msg}]`)
break
}
}
}
// Terminal-state gate.
if (event.type === 'session.status_terminated') break
if (
event.type === 'session.status_idle' &&
event.stop_reason?.type !== 'requires_action'
) {
break
}
}
clearInterval(heartbeat)
controller.close()
} catch (err) {
clearInterval(heartbeat)
send(`\n\n[stream error: ${err.message || err}]`)
controller.close()
}
},
cancel() {
// If the client disconnects, abort the upstream stream.
try {
upstream.controller?.abort?.()
} catch {
/* ignore */
}
},
})
function describeToolUse(event) {
const name = event.name || 'tool'
const input = event.input || {}
if (name === 'web_search' && input.query) return `🔍 Searching: ${input.query}`
if (name === 'web_fetch' && input.url) return `🌐 Fetching: ${input.url}`
if (event.type === 'agent.mcp_tool_use') {
const args = Object.entries(input)
.filter(([k]) => k !== 'limit')
.slice(0, 3)
.map(([k, v]) =>
typeof v === 'object' ? `${k}=…` : `${k}=${String(v).slice(0, 40)}`,
)
.join(', ')
return `🛠️ ${name}${args ? ` (${args})` : ''}`
}
return `🛠️ ${name}`
}
return new Response(stream, {
status: 200,
headers: {
'Content-Type': 'text/plain; charset=utf-8',
'Cache-Control': 'no-cache, no-transform',
'X-Accel-Buffering': 'no',
},
})
}
export const config = {
path: '/.netlify/functions/agent-proxy',
}
+97
View File
@@ -0,0 +1,97 @@
/**
* Netlify Function (Node runtime).
*
* Inserts a row into the Supabase `funder_data_submissions` table
* when the user submits the funder discovery form. Runs in parallel
* with the Anthropic agent stream and is intentionally independent of
* it — a Supabase failure must never block (or be observed by) the
* streaming brief.
*
* Environment variables (set in .env.local for local `netlify dev`,
* and in the Netlify UI for production):
* - SUPABASE_URL e.g. https://<project>.supabase.co
* - SUPABASE_SERVICE_ROLE_KEY service role key (server-side only)
*
* The service role key bypasses RLS, so this function must only be
* called from trusted server-side code (which it is — this endpoint
* is the only caller). Do NOT expose this key to the browser.
*/
export const handler = async (event) => {
if (event.httpMethod !== 'POST') {
return {
statusCode: 405,
body: JSON.stringify({ error: 'Method not allowed' }),
}
}
const SUPABASE_URL = process.env.SUPABASE_URL
const SUPABASE_SERVICE_ROLE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY
if (!SUPABASE_URL || !SUPABASE_SERVICE_ROLE_KEY) {
return {
statusCode: 500,
body: JSON.stringify({
error: 'Server is missing SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY',
}),
}
}
let payload
try {
payload = JSON.parse(event.body || '{}')
} catch {
return {
statusCode: 400,
body: JSON.stringify({ error: 'Invalid JSON body' }),
}
}
const row = {
name: payload.userName ?? null,
email: payload.userEmail ?? null,
station_name: payload.stationName ?? null,
station_location: payload.stationLocation ?? null,
station_website: payload.stationWebsite ?? null,
}
try {
const res = await fetch(
`${SUPABASE_URL.replace(/\/$/, '')}/rest/v1/funder_data_submissions`,
{
method: 'POST',
headers: {
apikey: SUPABASE_SERVICE_ROLE_KEY,
Authorization: `Bearer ${SUPABASE_SERVICE_ROLE_KEY}`,
'Content-Type': 'application/json',
Prefer: 'return=minimal',
},
body: JSON.stringify(row),
},
)
if (!res.ok) {
const text = await res.text()
console.error('Supabase insert failed', res.status, text)
return {
statusCode: 502,
body: JSON.stringify({
error: `Supabase insert failed: ${res.status} ${text}`,
}),
}
}
return {
statusCode: 204,
body: '',
}
} catch (err) {
console.error('Supabase request errored', err)
return {
statusCode: 500,
body: JSON.stringify({
error: `Supabase request errored: ${err?.message || String(err)}`,
}),
}
}
}
+1895 -66
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -10,13 +10,15 @@
"preview": "vite preview"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.95.2",
"docx": "^9.6.1",
"file-saver": "^2.0.5",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-markdown": "^9.0.1",
"remark-gfm": "^4.0.0"
},
"devDependencies": {
"@netlify/edge-functions": "^2.11.1",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
+236 -31
View File
@@ -1,4 +1,4 @@
import { useState, useRef, useEffect } from 'react'
import { useState, useRef, useEffect, useCallback } from 'react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
@@ -20,7 +20,9 @@ export default function App() {
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 [copied, setCopied] = useState(false)
const resultRef = useRef(null)
useEffect(() => {
@@ -29,6 +31,33 @@ export default function App() {
}
}, [result])
const handleCopy = useCallback(async () => {
try {
await navigator.clipboard.writeText(result)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch {
/* clipboard API unavailable */
}
}, [result])
const handleDownloadDocx = useCallback(async () => {
const [{ Packer }, { saveAs }, { markdownToDocx }] = await Promise.all([
import('docx'),
import('file-saver'),
import('./markdownToDocx'),
])
const title = form.stationName
? `Funding Brief — ${form.stationName}`
: 'PMC Funding Brief'
const doc = markdownToDocx(result, title)
const blob = await Packer.toBlob(doc)
const filename = form.stationName
? `${form.stationName.replace(/[^\w\s-]/g, '').replace(/\s+/g, '_')}_Funding_Brief.docx`
: 'PMC_Funding_Brief.docx'
saveAs(blob, filename)
}, [result, form.stationName])
const handleChange = (e) => {
const { name, value } = e.target
setForm((prev) => ({ ...prev, [name]: value }))
@@ -47,27 +76,91 @@ export default function App() {
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 firstChunk = true
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
const chunk = decoder.decode(value, { stream: true })
if (firstChunk) {
// Once data starts flowing, flip 'thinking' off and 'streaming' on.
buffer += decoder.decode(value, { stream: true })
if (firstByte) {
// Once any bytes arrive, flip 'thinking' off and 'streaming' on.
setIsThinking(false)
setIsStreaming(true)
firstChunk = false
firstByte = false
}
setResult((prev) => prev + chunk)
flushLines()
}
// Flush any remaining buffered bytes
const tail = decoder.decode()
if (tail) setResult((prev) => prev + tail)
setIsStreaming(false)
// 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) => {
@@ -75,25 +168,55 @@ export default function App() {
if (!validate()) return
setResult('')
setStatus(null)
setStreamError(null)
setIsSubmitting(true)
setIsThinking(true)
setIsStreaming(false)
// Fire-and-forget: save the submission to Supabase in parallel
// with the Anthropic agent stream. Any failure is logged but must
// never block or surface in the streaming UX.
fetch('/.netlify/functions/save-submission', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(form),
}).catch((err) => {
console.error('Failed to save submission to Supabase', err)
})
try {
const response = await fetch('/.netlify/functions/agent-proxy', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(form),
})
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
if (!response.ok || !response.body) {
throw new Error(
`Request failed: ${response.status} ${response.statusText}`,
)
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
}
await readStream(response)
} catch (err) {
console.error(err)
setStreamError(err.message || 'Something went wrong while streaming.')
@@ -101,6 +224,7 @@ export default function App() {
setIsSubmitting(false)
setIsThinking(false)
setIsStreaming(false)
setStatus(null)
}
}
@@ -184,6 +308,12 @@ export default function App() {
</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}
@@ -191,15 +321,38 @@ export default function App() {
)}
{(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>
<div>
<article
ref={resultRef}
className="markdown max-h-[1000px] 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>
{result && !isStreaming && (
<div className="mt-3 flex gap-2">
<button
type="button"
onClick={handleDownloadDocx}
className="inline-flex items-center gap-2 rounded-md border border-stone-300 bg-white px-4 py-2 text-sm font-medium text-stone-700 shadow-sm transition hover:bg-stone-50"
>
<DownloadIcon />
Download .docx
</button>
<button
type="button"
onClick={handleCopy}
className="inline-flex items-center gap-2 rounded-md border border-stone-300 bg-white px-4 py-2 text-sm font-medium text-stone-700 shadow-sm transition hover:bg-stone-50"
>
<ClipboardIcon />
{copied ? 'Copied!' : 'Copy text'}
</button>
</div>
)}
</div>
)}
</section>
</div>
@@ -228,6 +381,23 @@ function Field({ label, name, type = 'text', value, onChange, error, placeholder
)
}
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">
@@ -236,6 +406,41 @@ function StatusBanner({ children }) {
)
}
function DownloadIcon() {
return (
<svg
className="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
)
}
function ClipboardIcon() {
return (
<svg
className="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" />
</svg>
)
}
function Spinner() {
return (
<svg
+341
View File
@@ -0,0 +1,341 @@
import {
Document,
Paragraph,
TextRun,
HeadingLevel,
AlignmentType,
ExternalHyperlink,
Table,
TableRow,
TableCell,
WidthType,
BorderStyle,
convertInchesToTwip,
} from 'docx'
const HEADING_MAP = {
1: HeadingLevel.HEADING_1,
2: HeadingLevel.HEADING_2,
3: HeadingLevel.HEADING_3,
4: HeadingLevel.HEADING_4,
}
/** Parse inline markdown (bold, italic, links, code) into TextRun / Hyperlink children. */
function parseInline(text) {
const children = []
// Match **bold**, *italic*, [link](url), `code`, or plain text.
const re =
/(\*\*\*(.+?)\*\*\*|\*\*(.+?)\*\*|\*(.+?)\*|\[([^\]]+)\]\(([^)]+)\)|`([^`]+)`)/g
let last = 0
let m
while ((m = re.exec(text)) !== null) {
if (m.index > last) {
children.push(new TextRun(text.slice(last, m.index)))
}
if (m[2]) {
// ***bold italic***
children.push(new TextRun({ text: m[2], bold: true, italics: true }))
} else if (m[3]) {
// **bold**
children.push(new TextRun({ text: m[3], bold: true }))
} else if (m[4]) {
// *italic*
children.push(new TextRun({ text: m[4], italics: true }))
} else if (m[5] && m[6]) {
// [text](url)
children.push(
new ExternalHyperlink({
children: [
new TextRun({
text: m[5],
style: 'Hyperlink',
}),
],
link: m[6],
}),
)
} else if (m[7]) {
// `code`
children.push(
new TextRun({
text: m[7],
font: 'Courier New',
size: 20,
}),
)
}
last = m.index + m[0].length
}
if (last < text.length) {
children.push(new TextRun(text.slice(last)))
}
return children.length ? children : [new TextRun(text)]
}
/** Convert a markdown string to a docx Document. */
export function markdownToDocx(markdown, title) {
const lines = markdown.split('\n')
const children = []
let i = 0
while (i < lines.length) {
const line = lines[i]
// Blank line → skip
if (!line.trim()) {
i++
continue
}
// Heading
const headingMatch = line.match(/^(#{1,4})\s+(.+)/)
if (headingMatch) {
const level = headingMatch[1].length
children.push(
new Paragraph({
heading: HEADING_MAP[level],
children: parseInline(headingMatch[2]),
}),
)
i++
continue
}
// Horizontal rule
if (/^(-{3,}|\*{3,}|_{3,})\s*$/.test(line)) {
children.push(
new Paragraph({
border: {
bottom: { style: BorderStyle.SINGLE, size: 6, color: 'AAAAAA' },
},
children: [],
}),
)
i++
continue
}
// Table — collect rows that start with |
if (line.trimStart().startsWith('|')) {
const tableLines = []
while (i < lines.length && lines[i].trimStart().startsWith('|')) {
tableLines.push(lines[i])
i++
}
const parsed = parseTable(tableLines)
if (parsed) children.push(parsed)
continue
}
// Unordered list item
const ulMatch = line.match(/^(\s*)[-*+]\s+(.+)/)
if (ulMatch) {
const indent = Math.floor(ulMatch[1].length / 2)
children.push(
new Paragraph({
bullet: { level: indent },
children: parseInline(ulMatch[2]),
}),
)
i++
continue
}
// Ordered list item
const olMatch = line.match(/^(\s*)\d+\.\s+(.+)/)
if (olMatch) {
const indent = Math.floor(olMatch[1].length / 3)
children.push(
new Paragraph({
numbering: { reference: 'default-numbering', level: indent },
children: parseInline(olMatch[2]),
}),
)
i++
continue
}
// Blockquote
const bqMatch = line.match(/^>\s?(.*)/)
if (bqMatch) {
children.push(
new Paragraph({
indent: { left: convertInchesToTwip(0.5) },
children: [
new TextRun({ text: bqMatch[1], italics: true, color: '666666' }),
],
}),
)
i++
continue
}
// Regular paragraph — consume consecutive non-blank, non-special lines
const paraLines = []
while (
i < lines.length &&
lines[i].trim() &&
!lines[i].match(/^#{1,4}\s/) &&
!lines[i].match(/^[-*+]\s/) &&
!lines[i].match(/^\d+\.\s/) &&
!lines[i].match(/^>\s?/) &&
!lines[i].match(/^(-{3,}|\*{3,}|_{3,})\s*$/) &&
!lines[i].trimStart().startsWith('|')
) {
paraLines.push(lines[i])
i++
}
if (paraLines.length) {
children.push(
new Paragraph({
spacing: { after: 120 },
children: parseInline(paraLines.join(' ')),
}),
)
}
}
return new Document({
title: title || 'PMC Funding Brief',
styles: {
default: {
document: {
run: {
font: 'Noto Serif',
size: 22, // 11 pt
color: '1f1b16',
},
paragraph: {
spacing: { after: 160, line: 276 }, // 1.15× line spacing
},
},
heading1: {
run: {
font: 'Noto Serif',
size: 40, // 20 pt
bold: true,
color: '1f1b16',
},
paragraph: {
spacing: { before: 360, after: 120 },
},
},
heading2: {
run: {
font: 'Noto Serif',
size: 32, // 16 pt
bold: true,
color: '1f1b16',
},
paragraph: {
spacing: { before: 300, after: 120 },
},
},
heading3: {
run: {
font: 'Noto Serif',
size: 28, // 14 pt
bold: true,
color: '1f1b16',
},
paragraph: {
spacing: { before: 240, after: 80 },
},
},
heading4: {
run: {
font: 'Noto Serif',
size: 24, // 12 pt
bold: true,
color: '1f1b16',
},
paragraph: {
spacing: { before: 200, after: 80 },
},
},
listParagraph: {
run: {
font: 'Noto Serif',
size: 22,
},
},
},
paragraphStyles: [
{
id: 'Hyperlink',
name: 'Hyperlink',
run: {
color: '1d4ed8',
underline: { type: 'single' },
font: 'Noto Serif',
size: 22,
},
},
],
},
numbering: {
config: [
{
reference: 'default-numbering',
levels: Array.from({ length: 9 }, (_, lvl) => ({
level: lvl,
format: 'decimal',
text: `%${lvl + 1}.`,
alignment: AlignmentType.START,
style: { paragraph: { indent: { left: convertInchesToTwip(0.5 * (lvl + 1)), hanging: convertInchesToTwip(0.25) } } },
})),
},
],
},
sections: [{ children }],
})
}
/** Parse GFM table lines into a docx Table. */
function parseTable(tableLines) {
// Filter out the separator row (|---|---|)
const rows = tableLines.filter(
(l) => !l.replace(/[|\-:\s]/g, '').length === false && !/^\|[\s-:|]+\|$/.test(l),
)
// Re-parse: first non-separator is header, rest are body
const dataRows = tableLines.filter((l) => !/^\|[\s\-:]+\|$/.test(l))
if (!dataRows.length) return null
const parseCells = (line) =>
line
.split('|')
.slice(1, -1) // drop leading/trailing empty from split
.map((c) => c.trim())
const headerCells = parseCells(dataRows[0])
const bodyRows = dataRows.slice(1).map(parseCells)
const makeCellParagraph = (text, bold) =>
new TableCell({
children: [
new Paragraph({
children: bold
? [new TextRun({ text, bold: true })]
: parseInline(text),
}),
],
})
const docxRows = [
new TableRow({
tableHeader: true,
children: headerCells.map((c) => makeCellParagraph(c, true)),
}),
...bodyRows.map(
(cells) =>
new TableRow({
children: cells.map((c) => makeCellParagraph(c, false)),
}),
),
]
return new Table({
rows: docxRows,
width: { size: 100, type: WidthType.PERCENTAGE },
})
}