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