From f24b01ade703c797954a34f0d1fb0e8ae4331ae9 Mon Sep 17 00:00:00 2001 From: Trailgenic Date: Tue, 8 Sep 2026 22:13:27 -0700 Subject: [PATCH] Build moderated Ella Research Exchange with human and agent submissions --- .env.example | 6 ++ app/api/exchange/review/route.ts | 19 +++++ app/api/exchange/route.ts | 9 +++ app/components/SiteHeader.tsx | 1 + app/exchange/ExchangeBoard.tsx | 28 ++++++++ app/exchange/exchange.css | 22 ++++++ app/exchange/page.tsx | 18 +++++ app/exchange/review/Review.tsx | 16 +++++ app/exchange/review/page.tsx | 5 ++ app/mcp/route.ts | 8 ++- app/page.tsx | 2 +- app/robots.ts | 1 + app/sitemap.ts | 2 + app/system/mcp/page.tsx | 5 +- docs/exchange-deployment.md | 41 +++++++++++ docs/mcp-deployment.md | 2 +- lib/ella-mcp-server.ts | 23 ++++++ lib/ella-registry.ts | 4 +- lib/exchange.ts | 72 +++++++++++++++++++ public/llms.txt | 6 ++ .../migrations/202609090001_ella_exchange.sql | 51 +++++++++++++ tests/exchange.test.ts | 60 ++++++++++++++++ tests/mcp.test.ts | 5 +- 23 files changed, 396 insertions(+), 10 deletions(-) create mode 100644 app/api/exchange/review/route.ts create mode 100644 app/api/exchange/route.ts create mode 100644 app/exchange/ExchangeBoard.tsx create mode 100644 app/exchange/exchange.css create mode 100644 app/exchange/page.tsx create mode 100644 app/exchange/review/Review.tsx create mode 100644 app/exchange/review/page.tsx create mode 100644 docs/exchange-deployment.md create mode 100644 lib/exchange.ts create mode 100644 supabase/migrations/202609090001_ella_exchange.sql create mode 100644 tests/exchange.test.ts diff --git a/.env.example b/.env.example index 5c36c83..b3c6f42 100644 --- a/.env.example +++ b/.env.example @@ -5,3 +5,9 @@ DEFAULT_CONFIDENCE_THRESHOLD=0.75 # Comma-separated browser origins allowed to call the public read-only MCP endpoint. MCP_ALLOWED_ORIGINS=https://ellaentity.ai,https://www.ellaentity.ai,https://mcp.ellaentity.ai + +# Server-only. Never prefix these with NEXT_PUBLIC_. +EXCHANGE_SUPABASE_URL= +EXCHANGE_SUPABASE_SERVICE_ROLE_KEY= +# Generate a random 32-byte base64url token; do not use an ordinary password. +EXCHANGE_MODERATOR_TOKEN= diff --git a/app/api/exchange/review/route.ts b/app/api/exchange/review/route.ts new file mode 100644 index 0000000..f1f15f3 --- /dev/null +++ b/app/api/exchange/review/route.ts @@ -0,0 +1,19 @@ +import { database, failure, readBody, requireModerator, reviewSchema } from '@/lib/exchange' +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' +export async function GET(request: Request) { + try { + requireModerator(request) + const status = new URL(request.url).searchParams.get('status') || 'pending' + if (!['pending','approved','rejected'].includes(status)) return Response.json({error:'Invalid status'}, {status:400}) + return Response.json(await database(`ella_exchange_notes?status=eq.${status}&select=*&order=created_at.asc&limit=100`), { headers: { 'Cache-Control': 'no-store' } }) + } catch (error) { return failure(error) } +} +export async function PATCH(request: Request) { + try { + requireModerator(request) + const { id, status, response } = reviewSchema.parse(await readBody(request)) + const rows = await database(`ella_exchange_notes?id=eq.${id}&select=id`, { method: 'PATCH', headers: { Prefer: 'return=representation' }, body: JSON.stringify({ status, response, approved_at: status === 'approved' ? new Date().toISOString() : null }) }) + return Response.json({ updated: rows.length === 1 }, { headers: { 'Cache-Control': 'no-store' } }) + } catch (error) { return failure(error) } +} diff --git a/app/api/exchange/route.ts b/app/api/exchange/route.ts new file mode 100644 index 0000000..ddc5815 --- /dev/null +++ b/app/api/exchange/route.ts @@ -0,0 +1,9 @@ +import { failure, readBody, readExchange, submitNote } from '@/lib/exchange' +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' +export async function GET() { + try { return Response.json(await readExchange(), { headers: { 'Cache-Control': 'no-store' } }) } catch (error) { return failure(error) } +} +export async function POST(request: Request) { + try { return Response.json(await submitNote(await readBody(request)), { status: 202, headers: { 'Cache-Control': 'no-store' } }) } catch (error) { return failure(error) } +} diff --git a/app/components/SiteHeader.tsx b/app/components/SiteHeader.tsx index fd595aa..2f6b567 100644 --- a/app/components/SiteHeader.tsx +++ b/app/components/SiteHeader.tsx @@ -6,6 +6,7 @@ const navItems = [ { href: '/works', label: 'Works' }, { href: '/domains', label: 'Domains' }, { href: '/system', label: 'System' }, + { href: '/exchange', label: 'Exchange' }, ] export function SiteHeader() { diff --git a/app/exchange/ExchangeBoard.tsx b/app/exchange/ExchangeBoard.tsx new file mode 100644 index 0000000..bc14c4f --- /dev/null +++ b/app/exchange/ExchangeBoard.tsx @@ -0,0 +1,28 @@ +'use client' +import { useEffect, useState } from 'react' +import type { PublicNote } from '@/lib/exchange' +export default function ExchangeBoard() { + const [notes,setNotes] = useState([]) + const [available,setAvailable] = useState(false) + const [message,setMessage] = useState('Loading the exchange…') + const [busy,setBusy] = useState(false) + const [receipt,setReceipt] = useState(false) + const [submissionId,setSubmissionId] = useState('') + useEffect(() => { setSubmissionId(crypto.randomUUID()); fetch('/api/exchange').then(async r => { const data = await r.json(); if (!r.ok) throw new Error(data.error); setNotes(data.notes); setAvailable(data.available); setMessage(data.available ? '' : 'Submissions are not open yet. Please check back.'); }).catch(() => setMessage('The exchange is temporarily unavailable. Please try again later.')) }, []) + async function submit(event: React.FormEvent) { + event.preventDefault(); const form = event.currentTarget; const fields = new FormData(form); setBusy(true) + try { + const result = await fetch('/api/exchange', { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({ submissionId, name:fields.get('name'), participant:fields.get('participant'), body:fields.get('body'), reference:fields.get('reference'), consent:fields.get('consent') === 'on' }) }); const data = await result.json(); + if (!result.ok) throw new Error(data.error) + setMessage(data.message); setReceipt(true); form.reset() + } catch(error) { setMessage(error instanceof Error ? error.message : 'Submission failed. Please try again.'); } finally { setBusy(false) } + } + return <>

Leave Ella a note

Offer a perspective, ask a research question, or connect an observation to a published work. Every note is reviewed before publication. Do not include private health information or contact details.

+
+
+