Initial
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.netlify
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# PMC Funder Discovery Tool
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
Add your Anthropic credentials (in `.env` for local dev, in the Netlify UI
|
||||||
|
for production):
|
||||||
|
|
||||||
|
```
|
||||||
|
ANTHROPIC_API_KEY=sk-ant-...
|
||||||
|
# Optional - if you've provisioned a Managed Agent in the Anthropic console:
|
||||||
|
ANTHROPIC_AGENT_ID=agent_...
|
||||||
|
# Optional - override the model:
|
||||||
|
ANTHROPIC_MODEL=claude-sonnet-4-5
|
||||||
|
```
|
||||||
|
|
||||||
|
## Run locally
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev # netlify dev (proxies Vite + functions on :8888)
|
||||||
|
```
|
||||||
|
|
||||||
|
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 UTF‑8 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.
|
||||||
|
|
||||||
|
## 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`.
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
Implementation Plan: React + Anthropic Managed Agent (Streaming)
|
||||||
|
1. Project Overview
|
||||||
|
|
||||||
|
Build a React frontend that collects user and public media station data. This data is sent to a Netlify Serverless Function (v2), which proxies a request to an Anthropic Managed Agent. The response must be streamed to bypass Netlify's 26-second execution limit and provide real-time UI feedback.
|
||||||
|
2. Technical Requirements
|
||||||
|
|
||||||
|
Frontend: React (Vite/CRA), Tailwind CSS (for UI).
|
||||||
|
|
||||||
|
Backend: Netlify Functions v2 (ESM).
|
||||||
|
|
||||||
|
API: Anthropic SDK (specifically using the Agent/Messages streaming endpoint).
|
||||||
|
|
||||||
|
State Management: React useState and ReadableStream API.
|
||||||
|
|
||||||
|
3. Step-by-Step Task List
|
||||||
|
Step 1: React Form Component
|
||||||
|
|
||||||
|
Create a form to capture the following fields:
|
||||||
|
|
||||||
|
userName (string)
|
||||||
|
|
||||||
|
userEmail (string)
|
||||||
|
|
||||||
|
stationName (string)
|
||||||
|
|
||||||
|
stationLocation (string)
|
||||||
|
|
||||||
|
stationWebsite (url)
|
||||||
|
|
||||||
|
Logic: Use a handleSubmit function that prevents default behavior and initializes the streaming fetch request.
|
||||||
|
Step 2: Netlify Function (The Proxy)
|
||||||
|
|
||||||
|
Create a file at netlify/functions/agent-proxy.js.
|
||||||
|
|
||||||
|
Configuration: Use Netlify v2 syntax (export default async (req, context) => { ... }).
|
||||||
|
|
||||||
|
Anthropic Integration: Initialize the Anthropic client with an environment variable ANTHROPIC_API_KEY.
|
||||||
|
|
||||||
|
Agent Call: Use the Anthropic Messages API, targeting the specific Agent ID (if using a pre-configured Managed Agent) or passing the system prompt that defines the agent's persona.
|
||||||
|
|
||||||
|
Stream Implementation: * Use anthropic.messages.stream.
|
||||||
|
|
||||||
|
Wrap the output in a ReadableStream.
|
||||||
|
|
||||||
|
Return a Response object with headers: { "Content-Type": "text/plain; charset=utf-8" }.
|
||||||
|
|
||||||
|
Step 3: Frontend Streaming Logic
|
||||||
|
|
||||||
|
Implement a readStream function inside the React component:
|
||||||
|
|
||||||
|
Use fetch() to call /.netlify/functions/agent-proxy.
|
||||||
|
|
||||||
|
Access response.body.getReader().
|
||||||
|
|
||||||
|
Use a while(true) loop to read chunks.
|
||||||
|
|
||||||
|
Update a result state variable by appending new chunks: setResult(prev => prev + chunk).
|
||||||
|
|
||||||
|
Step 4: UI/UX Indicators
|
||||||
|
|
||||||
|
Initial Loading: Display a "Connecting to Agent..." spinner immediately upon submission.
|
||||||
|
|
||||||
|
Thinking State: Show a "Claude is thinking..." status until the first byte of data is received.
|
||||||
|
|
||||||
|
Streaming State: Render the incoming markdown/text in a dedicated container. Use a "pulse" icon at the end of the text to indicate the stream is still active.
|
||||||
|
|
||||||
|
4. Specific Prompt for Code Generation
|
||||||
|
|
||||||
|
When you hand this to an LLM, use the following prompt:
|
||||||
|
|
||||||
|
"Please generate the code for a React application and a Netlify v2 Function based on the provided plan.
|
||||||
|
|
||||||
|
Ensure the Netlify function uses the ReadableStream API to pipe Anthropic's content_block_delta events directly to the frontend.
|
||||||
|
|
||||||
|
In the React app, use a TextDecoder to handle the stream chunks.
|
||||||
|
|
||||||
|
Add a 'Thinking' state that is true while waiting for the first chunk and false once data starts flowing.
|
||||||
|
|
||||||
|
Ensure the Anthropic call is configured for a Managed Agent workflow (include a placeholder for the Agent ID or System Prompt)."
|
||||||
|
|
||||||
|
5. Architectural Diagram
|
||||||
|
|
||||||
|
The following diagram illustrates how the streaming data flows from Anthropic through the serverless "pipe" to your user's screen without hitting the timeout wall.
|
||||||
|
6. Security & Configuration
|
||||||
|
|
||||||
|
Environment Variables: Remind the developer to set ANTHROPIC_API_KEY in the Netlify UI.
|
||||||
|
|
||||||
|
CORS: Ensure the Netlify function handles the request within the same origin to avoid CORS issues.
|
||||||
|
|
||||||
|
Validation: Add basic regex validation for the station's website URL before sending it to the API.
|
||||||
|
|
||||||
|
Implementation Note for the Managed Agent
|
||||||
|
|
||||||
|
Since "Managed Agents" in Anthropic often involve specific tools or long-running threads, the code should be prepared to handle tool_use blocks or input_json if the agent needs to "call back" for more information about the station.
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Noto+Serif:ital,wght@0,400;0,500;0,600;0,700;1,400&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
<title>PMC Funder Tool</title>
|
||||||
|
</head>
|
||||||
|
<body class="font-serif">
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
[build]
|
||||||
|
command = "npm run build"
|
||||||
|
publish = "dist"
|
||||||
|
functions = "netlify/functions"
|
||||||
|
|
||||||
|
[functions]
|
||||||
|
node_bundler = "esbuild"
|
||||||
|
|
||||||
|
[dev]
|
||||||
|
framework = "vite"
|
||||||
|
targetPort = 5173
|
||||||
|
port = 8888
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import Anthropic from '@anthropic-ai/sdk'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Netlify Functions v2 handler.
|
||||||
|
*
|
||||||
|
* Proxies a request from the React frontend to the Anthropic Messages API,
|
||||||
|
* configured for a Managed Agent workflow. We pipe `content_block_delta`
|
||||||
|
* events directly to the client through a ReadableStream so we never hit
|
||||||
|
* Netlify's 26-second synchronous execution limit.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Managed Agent configuration
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// If you have provisioned a Managed Agent in the Anthropic console, set its
|
||||||
|
// ID here (or via the AGENT_ID env var). When AGENT_ID is present the call
|
||||||
|
// is routed to that agent. Otherwise we fall back to a system-prompt-driven
|
||||||
|
// "virtual agent" that defines the persona inline.
|
||||||
|
const AGENT_ID = process.env.ANTHROPIC_AGENT_ID || 'YOUR_AGENT_ID_HERE'
|
||||||
|
|
||||||
|
const SYSTEM_PROMPT = `You are the "PMC Funder Discovery Agent", an expert
|
||||||
|
fundraising consultant for U.S. public media stations (NPR/PBS member
|
||||||
|
stations, community radio, and independent public broadcasters).
|
||||||
|
|
||||||
|
Your job is to take a brief intake form about a public media station and
|
||||||
|
produce a tailored funding outlook that includes:
|
||||||
|
1. A short profile of the station based on the supplied details.
|
||||||
|
2. 3-5 likely foundation funders that match the station's mission and
|
||||||
|
geography (CPB, Knight, MacArthur, Wyncote, regional community
|
||||||
|
foundations, etc.).
|
||||||
|
3. 2-3 corporate underwriting angles relevant to the station's market.
|
||||||
|
4. Concrete next steps for the development director.
|
||||||
|
|
||||||
|
Write in clear, direct prose. Use Markdown headings and bullet lists. If
|
||||||
|
you need additional information, you may emit a tool_use block requesting
|
||||||
|
it; otherwise produce the brief in one pass. Avoid speculation about
|
||||||
|
specific dollar amounts.`
|
||||||
|
|
||||||
|
const MODEL = process.env.ANTHROPIC_MODEL || 'claude-sonnet-4-5'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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 {
|
||||||
|
userName = '',
|
||||||
|
userEmail = '',
|
||||||
|
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 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!process.env.ANTHROPIC_API_KEY) {
|
||||||
|
return new Response('Server is missing ANTHROPIC_API_KEY', { status: 500 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY })
|
||||||
|
|
||||||
|
const userMessage = `Here is a new public media station intake:
|
||||||
|
|
||||||
|
- Submitter Name: ${userName}
|
||||||
|
- Submitter Email: ${userEmail}
|
||||||
|
- Station Name: ${stationName}
|
||||||
|
- Station Location: ${stationLocation}
|
||||||
|
- Station Website: ${stationWebsite}
|
||||||
|
|
||||||
|
Please draft the funding outlook brief described in your instructions.`
|
||||||
|
|
||||||
|
// ----- Build the streaming request -----
|
||||||
|
// Note: when calling a Managed Agent, the Anthropic SDK accepts an
|
||||||
|
// `agent_id` field on the messages.stream() call. We include it
|
||||||
|
// conditionally so the same code path also works with a plain system
|
||||||
|
// prompt while you're wiring things up.
|
||||||
|
const streamArgs = {
|
||||||
|
model: MODEL,
|
||||||
|
max_tokens: 10000,
|
||||||
|
system: SYSTEM_PROMPT,
|
||||||
|
messages: [{ role: 'user', content: userMessage }],
|
||||||
|
}
|
||||||
|
if (AGENT_ID && AGENT_ID !== 'YOUR_AGENT_ID_HERE') {
|
||||||
|
streamArgs.agent_id = AGENT_ID
|
||||||
|
}
|
||||||
|
|
||||||
|
const upstream = client.messages.stream(streamArgs)
|
||||||
|
|
||||||
|
// ----- Pipe content_block_delta events straight to the client -----
|
||||||
|
const encoder = new TextEncoder()
|
||||||
|
const stream = new ReadableStream({
|
||||||
|
async start(controller) {
|
||||||
|
try {
|
||||||
|
for await (const event of upstream) {
|
||||||
|
if (
|
||||||
|
event.type === 'content_block_delta' &&
|
||||||
|
event.delta?.type === 'text_delta' &&
|
||||||
|
event.delta.text
|
||||||
|
) {
|
||||||
|
controller.enqueue(encoder.encode(event.delta.text))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Surface an explicit message if the agent decides to call a tool.
|
||||||
|
// (Managed Agents may emit `input_json_delta` or `tool_use` blocks.)
|
||||||
|
if (
|
||||||
|
event.type === 'content_block_delta' &&
|
||||||
|
event.delta?.type === 'input_json_delta' &&
|
||||||
|
event.delta.partial_json
|
||||||
|
) {
|
||||||
|
controller.enqueue(
|
||||||
|
encoder.encode(
|
||||||
|
`\n\n_[agent requested tool input: ${event.delta.partial_json}]_\n\n`,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
controller.close()
|
||||||
|
} catch (err) {
|
||||||
|
controller.enqueue(
|
||||||
|
encoder.encode(`\n\n[stream error: ${err.message || err}]`),
|
||||||
|
)
|
||||||
|
controller.close()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
cancel() {
|
||||||
|
// If the client disconnects, abort the upstream Anthropic request.
|
||||||
|
upstream.controller?.abort?.()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
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',
|
||||||
|
}
|
||||||
Generated
+3006
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "pmc-funder-tool",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "netlify dev",
|
||||||
|
"vite": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@anthropic-ai/sdk": "^0.32.1",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-markdown": "^9.0.1",
|
||||||
|
"remark-gfm": "^4.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"postcss": "^8.4.49",
|
||||||
|
"tailwindcss": "^3.4.17",
|
||||||
|
"vite": "^5.4.11"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
+261
@@ -0,0 +1,261 @@
|
|||||||
|
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 [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
|
||||||
|
}
|
||||||
|
|
||||||
|
const readStream = async (response) => {
|
||||||
|
const reader = response.body.getReader()
|
||||||
|
const decoder = new TextDecoder('utf-8')
|
||||||
|
let firstChunk = true
|
||||||
|
|
||||||
|
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.
|
||||||
|
setIsThinking(false)
|
||||||
|
setIsStreaming(true)
|
||||||
|
firstChunk = false
|
||||||
|
}
|
||||||
|
setResult((prev) => prev + chunk)
|
||||||
|
}
|
||||||
|
// Flush any remaining buffered bytes
|
||||||
|
const tail = decoder.decode()
|
||||||
|
if (tail) setResult((prev) => prev + tail)
|
||||||
|
setIsStreaming(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!validate()) return
|
||||||
|
|
||||||
|
setResult('')
|
||||||
|
setStreamError(null)
|
||||||
|
setIsSubmitting(true)
|
||||||
|
setIsThinking(true)
|
||||||
|
setIsStreaming(false)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/.netlify/functions/agent-proxy', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(form),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok || !response.body) {
|
||||||
|
throw new Error(
|
||||||
|
`Request failed: ${response.status} ${response.statusText}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
await readStream(response)
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
setStreamError(err.message || 'Something went wrong while streaming.')
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false)
|
||||||
|
setIsThinking(false)
|
||||||
|
setIsStreaming(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{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 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
+126
@@ -0,0 +1,126 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
font-family: 'Noto Serif', serif;
|
||||||
|
background-color: #f7f4ee;
|
||||||
|
color: #1f1b16;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-cursor {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 0.2;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cursor-pulse {
|
||||||
|
display: inline-block;
|
||||||
|
width: 0.5rem;
|
||||||
|
height: 1.1rem;
|
||||||
|
margin-left: 0.25rem;
|
||||||
|
background: currentColor;
|
||||||
|
vertical-align: middle;
|
||||||
|
animation: pulse-cursor 1s infinite ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Markdown rendering for streamed agent output ---- */
|
||||||
|
.markdown {
|
||||||
|
font-family: 'Noto Serif', serif;
|
||||||
|
color: #1f1b16;
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
.markdown h1,
|
||||||
|
.markdown h2,
|
||||||
|
.markdown h3,
|
||||||
|
.markdown h4,
|
||||||
|
.markdown h5,
|
||||||
|
.markdown h6 {
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.25;
|
||||||
|
margin-top: 1.5em;
|
||||||
|
margin-bottom: 0.5em;
|
||||||
|
}
|
||||||
|
.markdown h1 { font-size: 1.875rem; }
|
||||||
|
.markdown h2 { font-size: 1.5rem; }
|
||||||
|
.markdown h3 { font-size: 1.25rem; }
|
||||||
|
.markdown h4 { font-size: 1.1rem; }
|
||||||
|
.markdown h1:first-child,
|
||||||
|
.markdown h2:first-child,
|
||||||
|
.markdown h3:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
.markdown p {
|
||||||
|
margin: 0.75em 0;
|
||||||
|
}
|
||||||
|
.markdown ul,
|
||||||
|
.markdown ol {
|
||||||
|
margin: 0.75em 0;
|
||||||
|
padding-left: 1.5em;
|
||||||
|
}
|
||||||
|
.markdown ul { list-style: disc; }
|
||||||
|
.markdown ol { list-style: decimal; }
|
||||||
|
.markdown li {
|
||||||
|
margin: 0.25em 0;
|
||||||
|
}
|
||||||
|
.markdown li > p { margin: 0.25em 0; }
|
||||||
|
.markdown a {
|
||||||
|
color: #1d4ed8;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.markdown a:hover { color: #1e40af; }
|
||||||
|
.markdown strong { font-weight: 600; }
|
||||||
|
.markdown em { font-style: italic; }
|
||||||
|
.markdown blockquote {
|
||||||
|
border-left: 3px solid #d6d3d1;
|
||||||
|
padding-left: 1em;
|
||||||
|
color: #57534e;
|
||||||
|
margin: 1em 0;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
.markdown code {
|
||||||
|
background: #f5f5f4;
|
||||||
|
padding: 0.1em 0.35em;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
.markdown pre {
|
||||||
|
background: #1f1b16;
|
||||||
|
color: #f5f5f4;
|
||||||
|
padding: 1em;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 1em 0;
|
||||||
|
}
|
||||||
|
.markdown pre code {
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 0.875em;
|
||||||
|
}
|
||||||
|
.markdown hr {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid #d6d3d1;
|
||||||
|
margin: 1.5em 0;
|
||||||
|
}
|
||||||
|
.markdown table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 1em 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.markdown th,
|
||||||
|
.markdown td {
|
||||||
|
border: 1px solid #d6d3d1;
|
||||||
|
padding: 0.5em 0.75em;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.markdown th {
|
||||||
|
background: #f5f5f4;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import App from './App.jsx'
|
||||||
|
import './index.css'
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>,
|
||||||
|
)
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: ['./index.html', './src/**/*.{js,jsx,ts,tsx}'],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
fontFamily: {
|
||||||
|
serif: ['"Noto Serif"', 'serif'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user