From 52504d0181d00919ba151e63b143ef3c303654f7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 14 Oct 2026 13:38:52 +0000 Subject: [PATCH 1/3] feat: add a Next.js chat UI that proxies to the API The console stores documents, asks questions, and shows the tool, mode, and cache flag. Same-origin /api routes forward to API_URL, which is what a Vercel project rooted at web uses. Co-authored-by: naveecode --- .gitignore | 2 + docker-compose.yml | 8 + web/.dockerignore | 3 + web/.gitignore | 2 + web/Dockerfile | 22 + web/app/api/ask/route.ts | 8 + web/app/api/chats/route.ts | 7 + web/app/api/docs/route.ts | 8 + web/app/api/health/route.ts | 7 + web/app/globals.css | 207 ++++++++ web/app/layout.tsx | 26 + web/app/page.tsx | 5 + web/components/console.tsx | 247 +++++++++ web/lib/upstream.ts | 21 + web/next-env.d.ts | 6 + web/next.config.ts | 9 + web/package-lock.json | 1001 +++++++++++++++++++++++++++++++++++ web/package.json | 20 + web/public/.gitkeep | 0 web/tsconfig.json | 21 + web/vercel.json | 3 + 21 files changed, 1633 insertions(+) create mode 100644 web/.dockerignore create mode 100644 web/.gitignore create mode 100644 web/Dockerfile create mode 100644 web/app/api/ask/route.ts create mode 100644 web/app/api/chats/route.ts create mode 100644 web/app/api/docs/route.ts create mode 100644 web/app/api/health/route.ts create mode 100644 web/app/globals.css create mode 100644 web/app/layout.tsx create mode 100644 web/app/page.tsx create mode 100644 web/components/console.tsx create mode 100644 web/lib/upstream.ts create mode 100644 web/next-env.d.ts create mode 100644 web/next.config.ts create mode 100644 web/package-lock.json create mode 100644 web/package.json create mode 100644 web/public/.gitkeep create mode 100644 web/tsconfig.json create mode 100644 web/vercel.json diff --git a/.gitignore b/.gitignore index 5cd3024..3bfb730 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ __pycache__/ .venv/ .pytest_cache/ +node_modules/ +.next/ diff --git a/docker-compose.yml b/docker-compose.yml index 257c65b..0e0b56e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -30,6 +30,14 @@ services: condition: service_healthy redis: condition: service_started + web: + build: ./web + ports: + - "3000:3000" + environment: + API_URL: http://api:8000 + depends_on: + - api redis: image: redis:7-alpine mongo: diff --git a/web/.dockerignore b/web/.dockerignore new file mode 100644 index 0000000..8b88447 --- /dev/null +++ b/web/.dockerignore @@ -0,0 +1,3 @@ +node_modules +.next +.git diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000..b90a368 --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,2 @@ +node_modules +.next diff --git a/web/Dockerfile b/web/Dockerfile new file mode 100644 index 0000000..c160a3a --- /dev/null +++ b/web/Dockerfile @@ -0,0 +1,22 @@ +FROM node:22-alpine AS deps +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci + +FROM node:22-alpine AS build +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . +ENV DOCKER_BUILD=1 +RUN npm run build + +FROM node:22-alpine AS run +WORKDIR /app +ENV NODE_ENV=production +ENV PORT=3000 +ENV HOSTNAME=0.0.0.0 +COPY --from=build /app/.next/standalone ./ +COPY --from=build /app/.next/static ./.next/static +COPY --from=build /app/public ./public +EXPOSE 3000 +CMD ["node", "server.js"] diff --git a/web/app/api/ask/route.ts b/web/app/api/ask/route.ts new file mode 100644 index 0000000..b8753aa --- /dev/null +++ b/web/app/api/ask/route.ts @@ -0,0 +1,8 @@ +import { proxy } from "@/lib/upstream"; + +export const dynamic = "force-dynamic"; + +export async function POST(request: Request) { + const body = await request.text(); + return proxy("/ask", { method: "POST", body }); +} diff --git a/web/app/api/chats/route.ts b/web/app/api/chats/route.ts new file mode 100644 index 0000000..755b2d7 --- /dev/null +++ b/web/app/api/chats/route.ts @@ -0,0 +1,7 @@ +import { proxy } from "@/lib/upstream"; + +export const dynamic = "force-dynamic"; + +export function GET() { + return proxy("/chats"); +} diff --git a/web/app/api/docs/route.ts b/web/app/api/docs/route.ts new file mode 100644 index 0000000..a2642b1 --- /dev/null +++ b/web/app/api/docs/route.ts @@ -0,0 +1,8 @@ +import { proxy } from "@/lib/upstream"; + +export const dynamic = "force-dynamic"; + +export async function POST(request: Request) { + const body = await request.text(); + return proxy("/docs", { method: "POST", body }); +} diff --git a/web/app/api/health/route.ts b/web/app/api/health/route.ts new file mode 100644 index 0000000..567c693 --- /dev/null +++ b/web/app/api/health/route.ts @@ -0,0 +1,7 @@ +import { proxy } from "@/lib/upstream"; + +export const dynamic = "force-dynamic"; + +export function GET() { + return proxy("/health"); +} diff --git a/web/app/globals.css b/web/app/globals.css new file mode 100644 index 0000000..a78faf4 --- /dev/null +++ b/web/app/globals.css @@ -0,0 +1,207 @@ +:root { + color-scheme: light; + --ink: #1c1916; + --muted: #6d645b; + --line: #e4dcd2; + --paper: #fffdf9; + --accent: #c24b2a; + --accent-ink: #fff8f4; + --chip: #f3ebe3; +} + +* { box-sizing: border-box; } + +body { + margin: 0; + min-height: 100vh; + color: var(--ink); + font-family: var(--font-sans), "Outfit", sans-serif; + background: + radial-gradient(880px 420px at 0% -10%, #f6d4c6 0%, transparent 60%), + radial-gradient(720px 380px at 100% 0%, #d7e6d4 0%, transparent 55%), + #f3efe8; +} + +button, input, textarea { font: inherit; color: inherit; } + +.shell { + width: min(1120px, calc(100% - 32px)); + margin: 0 auto; + padding: 36px 0 48px; +} + +.top { + display: flex; + justify-content: space-between; + gap: 24px; + align-items: flex-end; + margin-bottom: 24px; +} + +.eyebrow { + margin: 0 0 4px; + letter-spacing: 0.14em; + text-transform: uppercase; + font-size: 12px; + color: var(--accent); +} + +h1 { + margin: 0; + font-family: var(--font-display), Georgia, serif; + font-weight: 560; + font-size: clamp(40px, 5vw, 64px); + letter-spacing: -0.03em; + line-height: 0.95; +} + +h2 { margin: 0 0 8px; font-size: 20px; } + +.pills { display: flex; flex-wrap: wrap; gap: 8px; justify-content: flex-end; } + +.pill, .chip { + display: inline-flex; + align-items: center; + border-radius: 999px; + padding: 6px 10px; + background: var(--paper); + border: 1px solid var(--line); + font-size: 13px; +} + +.pill.bad { background: #fde8e2; border-color: #e7b2a4; color: #8a2e16; } + +.layout { + display: grid; + grid-template-columns: 320px 1fr; + gap: 18px; + align-items: start; +} + +.panel, .chat { + background: var(--paper); + border: 1px solid var(--line); + border-radius: 22px; + box-shadow: 0 18px 50px rgba(60, 40, 20, 0.06); +} + +.panel { padding: 22px; } + +.lede, .muted { color: var(--muted); } + +.lede { margin: 0 0 16px; line-height: 1.45; } + +.stack, .composer { display: grid; gap: 12px; } + +label { display: grid; gap: 6px; font-size: 14px; } + +input, textarea { + width: 100%; + border: 1px solid var(--line); + background: #fff; + border-radius: 12px; + padding: 12px 14px; +} + +textarea { resize: vertical; } + +button { + border: 0; + border-radius: 12px; + padding: 12px 16px; + background: var(--accent); + color: var(--accent-ink); + cursor: pointer; +} + +button:disabled { opacity: 0.55; cursor: not-allowed; } + +button.ghost { + background: transparent; + color: var(--ink); + border: 1px solid var(--line); +} + +.note { margin: 0; color: #2f6f4e; } + +.chat { display: grid; grid-template-rows: 1fr auto auto; min-height: 640px; padding: 18px; } + +.transcript { display: grid; gap: 18px; align-content: start; } + +.empty { + margin: auto 0; + padding: 28px 8px; + color: var(--muted); +} + +.empty p { margin: 0 0 8px; } + +.exchange { display: grid; gap: 6px; } + +.who { + margin: 0; + font-size: 12px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--muted); +} + +.question { margin: 0; font-size: 18px; } + +.answer { + background: #faf7f3; + border-radius: 16px; + padding: 12px 14px; +} + +.meta { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; } + +.chip { background: var(--chip); font-size: 12px; padding: 3px 8px; } + +.chip.quiet { background: transparent; } + +.plain { margin: 0; line-height: 1.45; white-space: pre-wrap; } + +.hits { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; } + +.hits li { margin: 0; } + +.hit-head { display: flex; justify-content: space-between; gap: 12px; font-variant-numeric: tabular-nums; } + +.doc { font-weight: 650; } + +.score { color: var(--muted); font-size: 13px; } + +.hits p { margin: 4px 0 0; } + +.suggestions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; } + +.composer { grid-template-columns: 1fr auto; margin-top: 12px; } + +.error { + margin: 12px 0 0; + padding: 10px 12px; + border-radius: 12px; + background: #fde8e2; + color: #8a2e16; +} + +.sr { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + border: 0; +} + +code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.92em; } + +@media (max-width: 800px) { + .layout { grid-template-columns: 1fr; } + .top { flex-direction: column; align-items: flex-start; } + .pills { justify-content: flex-start; } + .chat { min-height: 520px; } +} diff --git a/web/app/layout.tsx b/web/app/layout.tsx new file mode 100644 index 0000000..b187319 --- /dev/null +++ b/web/app/layout.tsx @@ -0,0 +1,26 @@ +import type { Metadata } from "next"; +import { Fraunces, Outfit } from "next/font/google"; +import "./globals.css"; + +const display = Fraunces({ + subsets: ["latin"], + variable: "--font-display", +}); + +const sans = Outfit({ + subsets: ["latin"], + variable: "--font-sans", +}); + +export const metadata: Metadata = { + title: "AgentOps", + description: "Chat with the document agent and watch which tool it calls.", +}; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/web/app/page.tsx b/web/app/page.tsx new file mode 100644 index 0000000..1ddf50e --- /dev/null +++ b/web/app/page.tsx @@ -0,0 +1,5 @@ +import { Console } from "@/components/console"; + +export default function HomePage() { + return ; +} diff --git a/web/components/console.tsx b/web/components/console.tsx new file mode 100644 index 0000000..4125e0a --- /dev/null +++ b/web/components/console.tsx @@ -0,0 +1,247 @@ +"use client"; + +import { FormEvent, useEffect, useState } from "react"; + +type Hit = { id: string; text: string; score: number }; +type Answer = string | number | Hit[]; + +type Turn = { + question: string; + answer: Answer; + mode?: string; + tool?: string; + cached?: boolean; +}; + +type Health = { + ok: boolean; + store: string; + queue: string; + cache: string; +}; + +const SUGGESTIONS = ["What is the refund policy?", "2*(3+4)"]; + +export function Console() { + const [health, setHealth] = useState(null); + const [offline, setOffline] = useState(false); + const [turns, setTurns] = useState([]); + const [question, setQuestion] = useState(""); + const [docId, setDocId] = useState(""); + const [docText, setDocText] = useState(""); + const [docNote, setDocNote] = useState(""); + const [asking, setAsking] = useState(false); + const [storing, setStoring] = useState(false); + const [error, setError] = useState(""); + + useEffect(() => { + let cancelled = false; + async function load() { + try { + const healthResponse = await fetch("/api/health"); + if (!healthResponse.ok) throw new Error("health"); + const healthBody = (await healthResponse.json()) as Health; + if (!cancelled) setHealth(healthBody); + } catch { + if (!cancelled) setOffline(true); + } + try { + const chatResponse = await fetch("/api/chats"); + if (!chatResponse.ok) return; + const body = (await chatResponse.json()) as { chats?: Turn[] }; + if (!cancelled) setTurns([...(body.chats ?? [])].reverse()); + } catch { + /* history is optional; the ask box still works */ + } + } + void load(); + return () => { + cancelled = true; + }; + }, []); + + async function ask(text: string) { + const trimmed = text.trim(); + if (!trimmed || asking) return; + setAsking(true); + setError(""); + try { + const response = await fetch("/api/ask", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ question: trimmed }), + }); + if (!response.ok) throw new Error("ask failed"); + const turn = (await response.json()) as Omit; + setTurns((current) => [...current, { question: trimmed, ...turn }]); + setQuestion(""); + } catch { + setError("The agent could not answer. Check that the API is running."); + } finally { + setAsking(false); + } + } + + async function onAsk(event: FormEvent) { + event.preventDefault(); + await ask(question); + } + + async function onStore(event: FormEvent) { + event.preventDefault(); + const id = docId.trim(); + const text = docText.trim(); + if (!id || !text || storing) return; + setStoring(true); + setDocNote(""); + setError(""); + try { + const response = await fetch("/api/docs", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ id, text }), + }); + if (!response.ok) throw new Error("store failed"); + const body = (await response.json()) as { status?: string }; + setDocNote(body.status === "queued" ? "Queued for the worker." : "Stored."); + setDocText(""); + } catch { + setError("The document was not stored. Check that the API is running."); + } finally { + setStoring(false); + } + } + + return ( +
+
+
+

AgentOps

+

Ask the documents.

+
+
+ {offline && API unreachable} + {health && ( + <> + store {health.store} + queue {health.queue} + cache {health.cache} + + )} +
+
+ +
+
+

Add a document

+

+ Stored text is what search_docs can retrieve. Arithmetic skips the index and uses the calculator. +

+
+ +