diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index afc3be8..a81feb0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,6 +38,24 @@ jobs: - run: ruff check . - run: pytest -q + web: + runs-on: ubuntu-latest + defaults: + run: + working-directory: web + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + cache-dependency-path: web/package-lock.json + - run: npm ci + - run: npm run lint + - run: npm test + - run: npm run build + - run: npm run typecheck + container: needs: test runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index dc51cc3..45773fd 100644 --- a/.gitignore +++ b/.gitignore @@ -19,8 +19,8 @@ dist/ downloads/ eggs/ .eggs/ -lib/ -lib64/ +/lib/ +/lib64/ parts/ sdist/ var/ diff --git a/DEPLOY.md b/DEPLOY.md index 8b1f319..9553102 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -9,6 +9,7 @@ FloatChat's live demo runs entirely on free tiers: | Keep-awake ping | [cron-job.org](https://cron-job.org) | calls `/ping` every 10 min so Render never sleeps | | Data loading | GitHub Actions | backfill once, refresh daily at 02:00 UTC | | LLM | [Groq](https://console.groq.com) | free API key | +| Web app (Next.js) | [Vercel](https://vercel.com) Hobby plan | static pages from a global CDN; the browser streams answers straight from the API | You create every account and paste every secret yourself. Nothing secret goes in the repo. @@ -75,7 +76,7 @@ You create every account and paste every secret yourself. Nothing secret goes in | `LLM_MODEL` | a current Groq chat model (see console.groq.com/docs/models), e.g. `openai/gpt-oss-120b` | | `LLM_API_KEY` | your Groq key | | `LLM_DAILY_BUDGET` | `2000` | - | `ALLOWED_ORIGINS` | `http://localhost:3000` for now; add the Vercel URL in Phase 3 | + | `ALLOWED_ORIGINS` | `http://localhost:3000` for now; add the Vercel URL in step 5 | 4. Click **Deploy web service**. Render passes the port in `$PORT`, which the image already uses. When the service is **Live**, check it: @@ -94,7 +95,33 @@ You create every account and paste every secret yourself. Nothing secret goes in 2. Render's free tier sleeps after 15 minutes without traffic and allows 750 instance hours a month, enough for one service running all month. `/ping` never touches the database, so Neon still suspends between real visits. -## 5. Streamlit Community Cloud: the web app (interim UI) +## 5. Vercel: the web app + +The site in `web/` is a Next.js app. Pages are rendered on Vercel and cached; the chat and the float charts call the +Render API straight from the visitor's browser, so each visitor's own IP counts against the rate limits and no +shared secret is needed. + +1. Sign in at **https://vercel.com** with GitHub and click **Add New → Project**, then import this repository. +2. Settings: + + | Setting | Value | + |---|---| + | Framework Preset | **Next.js** (detected) | + | Root Directory | `web` | + | Environment Variable | `NEXT_PUBLIC_API_URL` = `https://.onrender.com` | + +3. Click **Deploy**. When it finishes, note the production URL (for example `https://floatchat.vercel.app`). +4. In **Render → your service → Environment**, set `ALLOWED_ORIGINS` to that URL plus local development, comma + separated, and save (Render redeploys): + + ```text + https://floatchat.vercel.app,http://localhost:3000 + ``` + +5. Open the site and ask a question. The run log should fill in step by step and the answer should stream in. + If you later add a custom domain on Vercel, add it to `ALLOWED_ORIGINS` too. + +## 6. Streamlit Community Cloud: the old interim UI (optional) The Streamlit app (`app.py`) runs free on Streamlit Community Cloud and talks to the Render API. Streamlit's servers call the API for every visitor, so the app passes each visitor's IP along with a shared secret (`FRONTEND_TOKEN`); @@ -136,6 +163,7 @@ without it, all visitors would share one rate limit. | LLM shows *missing or invalid LLM_API_KEY* | Re-paste the Groq key in Render's environment variables (it redeploys automatically) | | The first request takes about a minute | The service went to sleep: check the cron-job.org job is enabled and succeeding | | Daily refresh failed | Usually ERDDAP being slow or down; the site keeps serving the previous data. Re-run the workflow later | +| The web app shows *Couldn't reach the FloatChat API* | Check `NEXT_PUBLIC_API_URL` on Vercel (then redeploy) and that the Vercel URL is in Render's `ALLOWED_ORIGINS` | | Streamlit app says it can't reach the API | Check `BACKEND_URL` in the app's **Settings → Secrets** (no trailing slash needed) and that the Render service is Live | | Streamlit visitors hit "Too many questions" quickly | `FRONTEND_TOKEN` must be identical in Render and in the Streamlit secrets | | Render's free tier changes | The API is a plain Docker image: any Docker host (Google Cloud Run, Hugging Face Spaces, Fly.io) works with the same environment variables | diff --git a/docs/superpowers/specs/2026-11-09-phase3-web-app-design.md b/docs/superpowers/specs/2026-11-09-phase3-web-app-design.md new file mode 100644 index 0000000..706fff9 --- /dev/null +++ b/docs/superpowers/specs/2026-11-09-phase3-web-app-design.md @@ -0,0 +1,99 @@ +# Phase 3: Next.js web app + +**Status:** design written to keep building overnight (user preference: build, then review). Everything below is +easy to change; the decisions most worth a look are marked ★. + +## Goal + +Replace the Streamlit stopgap with a fast, good-looking site a recruiter can open on a phone: a landing page that +explains how FloatChat works and lets you try it, a chat that shows its work live, and float pages with real +oceanography views. Architecture A stays: FastAPI owns all data and AI; the site only displays results. + +## Decisions + +- ★ **Where:** `web/` in this repo (one repo, one README). Vercel project root directory = `web`. +- **Stack:** Next.js (App Router, TypeScript, React Server Components), Tailwind CSS, Observable Plot for charts, + d3-geo + topojson for maps. No map tiles, no API keys, nothing paid. +- ★ **Calls go from the browser straight to the API** (`NEXT_PUBLIC_API_URL`). Each visitor's real IP reaches the + API's rate limiter through Cloudflare, so no proxy or shared token is needed, and SSE streams directly. Render's + `ALLOWED_ORIGINS` must include the Vercel domain. Landing-page numbers are fetched on the server with + revalidation (10 minutes), so the page renders instantly and costs the API almost nothing. +- **Benchmark score** comes from `eval/latest.json` on GitHub (raw URL), revalidated daily, so the site always shows + the latest weekly run without a redeploy. +- ★ **Look ("scientific instrument", not "AI product"):** the generic AI palette (purple/indigo glows, cyan on navy, + gradient text) is out. Instead: + - **Neutrals:** graphite. Dark `#0d0e10` → `#16181b` surfaces, light `#f3f4f2` → `#ffffff`; hairline borders. + Both themes, following the system setting, with a toggle. + - **One UI accent: marine safety orange** (`#ff5b1f` dark / `#e2470f` light), the colour of buoys and ocean + instruments. Used only for the primary action, focus, live state and the active step. + - **Data colours are the oceanographers' own:** cmocean `thermal` (temperature), `haline` (salinity), `oxy` + (oxygen), `deep` (depth), exported from the cmocean package. UI accent and data colours never mix. + - **Type:** Geist for text, Geist Mono for numbers, SQL and the run log. Tabular numerals everywhere data appears. + - **Shape:** cards 14 px, inputs and buttons full pill, chips 8 px. Icons from Phosphor (one weight). + - **Motion (dial 5):** spring entrances, steps sliding into the run log as they happen, globe auto-rotation; + all of it off under `prefers-reduced-motion`. +- **Agent UI, from current agent-UX guidance** (visible tool calls with inputs/outputs, a chronological action log, + calibrated confidence instead of long reasoning prose, explicit stop/retry controls): + - each answer opens with a compact **run log**: one row per step (Understood, SQL attempt n, Search, Writing) with + a status icon, duration and an expandable detail (filters, SQL text, rows, matches); + - honest signals are first-class: "keyword fallback" when the LLM router wasn't used, "repaired SQL" when a retry + happened, "no exact match, widened" when filters found nothing; + - **Stop** cancels the stream; **Retry** re-asks; **Copy SQL**; suggested follow-ups as chips. +- Light/dark contrast WCAG AA. Works at 360 px width. + +## Pages + +### `/` Landing +1. **Hero:** "Ask the Indian Ocean anything" over a rotating globe (canvas, orthographic d3-geo projection, drag to + spin) with every active float as a dot coloured by surface temperature; click a float to open its page. + Live counters: floats, profiles, measurements, newest data date. +2. **Try it:** a question box plus sample-question chips; submitting opens `/chat?q=…` and starts streaming. +3. **How it works:** a scroll-through of five steps with small diagrams: Argo floats dive → nightly pipeline (ERDDAP, + QC, 24 standard depths, descriptions, embeddings) → Postgres + pgvector → router (LLM with keyword fallback) picks + SQL or search and filters → answer with the step trace. Each step names the real component. +4. **Proof:** benchmark card (questions fully correct, routing / filters / answer checks, median latency) linking to + the per-question table; stack badges; GitHub link. + +### `/chat` +- Streams `POST /query/stream` with `fetch` + a `ReadableStream` SSE parser (EventSource can't POST). +- **Live step trace** above each answer: Understood (router, path, analysis, filters as chips) → SQL attempt(s) with + rows and ms, or Search with match count → Writing. Steps appear as events arrive; the answer text streams in. +- Result views: per-analysis chart (depth profile lines, region bars, depth-layer bars, monthly lines, top floats, + BGC by zone), the SQL (collapsible, monospace), the data table; for searches, matching profiles as cards with a + mini map and links to float pages. +- Follow-ups: the last 3 answered turns go along as `history`. Clear 429 / network / cold-start messages. + +### `/floats/[id]` +- Summary (WMO id, profiles, first/last date, sensors, region), trajectory map, latest profile (temperature, + salinity, oxygen vs depth), **T-S diagram** (all profiles, dots coloured by depth), **depth-time section** + (temperature as a heatmap, date × depth). Built from `/float/{id}`, `/profiles/float/{id}` and `/get_profiles`. + +### `/compare?floats=a,b` +- Two floats side by side: overlaid T-S diagrams and mean temperature/salinity profiles. Entry from a float page. + +## API changes + +None planned; the existing endpoints cover every view. If a view needs a cheaper query (e.g. a float's full +depth-time grid in one call) it is added as a read-only endpoint with a test. + +## Testing + +- Vitest unit tests for the SSE parser, the step-trace reducer and the chart data shaping. +- CI job: `npm ci`, lint, type-check, unit tests, `next build`. +- Manual check in the browser pane against the live API (desktop and 375 px), with screenshots for the PR. + +## Deployment (user steps, documented in DEPLOY.md) + +Import the repo on Vercel with root directory `web`, set `NEXT_PUBLIC_API_URL`, then add the Vercel domain to +`ALLOWED_ORIGINS` on Render. + +## Non-goals + +Accounts, server-side chat history, retiring Streamlit and the launch video (Phase 4). + +## Done criteria + +- [ ] Landing page live on Vercel with real numbers, globe, how-it-works and the benchmark score. +- [ ] Chat streams steps and tokens from the live API, follow-ups work, charts render per analysis. +- [ ] Float page shows trajectory, T-S diagram and depth-time section; compare works for two floats. +- [ ] CI builds and tests the web app; Lighthouse performance and accessibility ≥ 90 on the landing page. diff --git a/floatchat/llm.py b/floatchat/llm.py index 63631d1..12ff52a 100644 --- a/floatchat/llm.py +++ b/floatchat/llm.py @@ -77,7 +77,10 @@ def chat_stream(prompt: str, system: str | None = None, temperature: float = 0.2 return try: resp = _post(_payload(prompt, system, temperature, max_tokens, json_mode=False, stream=True), stream=True) - for line in resp.iter_lines(decode_unicode=True): + # Read bytes and decode as UTF-8: event streams often have no charset, and requests would then fall back to + # ISO-8859-1 and garble characters such as "µ" or narrow spaces + for raw in resp.iter_lines(): + line = raw.decode("utf-8", errors="replace") if isinstance(raw, bytes) else raw if not line or not line.startswith("data: "): continue data = line[len("data: "):] diff --git a/readme.md b/readme.md index 10118fd..813fce3 100644 --- a/readme.md +++ b/readme.md @@ -134,7 +134,22 @@ Because the demo is public, the API limits each visitor to 10 questions a minute day (falling back to data-only answers when the budget runs out), and caches answers until new data arrives. Interactive API docs are at `http://localhost:8000/docs` while the backend is running. -### 5. Frontend: the Streamlit app (`app.py`) +### 5. Frontend: the web app (`web/`) + +A Next.js site (TypeScript, Tailwind, Observable Plot, d3-geo) deployed on Vercel. It only displays results; all data +and AI logic stays in the API. + +- **Landing page:** a live globe of every float coloured by surface temperature, the dataset's numbers, sample + questions, a scroll-through of how an answer is made, and the latest benchmark score. +- **Chat:** streams `/query/stream`. Each answer opens with a **run log** (what the router understood, each SQL + attempt with rows and timing, the search, who wrote the text), then the answer as it is written, then a chart, + table and SQL, or the matching profiles on a map. Follow-ups, Stop and Retry work. +- **Float pages:** trajectory, latest profile, a depth-time section (temperature, salinity or oxygen) and a + temperature-salinity diagram; a comparison page overlays two floats. +- Colours for data are the oceanographers' own cmocean scales; light and dark themes follow the system setting. + +### 5b. The interim Streamlit app (`app.py`) + - **Overview page:** headline numbers, a live map of every float coloured by surface temperature, profiles per region, and a **float explorer**. Click any float on the map to see its trajectory and latest depth profiles. @@ -155,14 +170,15 @@ Interactive API docs are at `http://localhost:8000/docs` while the backend is ru | `docker-compose.yml` | Runs PostgreSQL + pgvector, the API (which loads the data on first start) and the frontend. | | `.github/workflows/` | `ci.yml` (lint, tests, container memory check), `refresh.yml` (daily data), `backfill.yml` (first load), `eval.yml` (weekly benchmark against the live API). | | `eval/` | 30 benchmark questions and the runner that scores the API (see [How well it works](#-how-well-it-works)). | -| `DEPLOY.md` | Step-by-step free deployment: Neon, Render, GitHub Actions, cron-job.org and Groq. | +| `DEPLOY.md` | Step-by-step free deployment: Neon, Render, Vercel, GitHub Actions, cron-job.org and Groq. | | `tests/` | Unit tests, plus integration tests against a real Postgres + pgvector (`tests/integration`). | ### Repository layout ``` FloatChat-AI/ -├── app.py # Streamlit frontend +├── web/ # Next.js web app (landing, chat, float pages) +├── app.py # interim Streamlit frontend ├── api/ # FastAPI app: endpoints, rate limits, caches ├── floatchat/ # core: SQL agent, search, answers, embedder, LLM client, database ├── pipeline/ # ERDDAP → standard depths → descriptions/embeddings → Postgres @@ -288,7 +304,15 @@ docker compose up -d db # PostgreSQL + pgvector on port 5433 python -m pipeline snapshot # load the bundled data (~30 s) python -m pipeline create-reader # the API's read-only role (password from READER_PASSWORD) uvicorn api.main:app --port 8000 # terminal 1: API -streamlit run app.py # terminal 2: frontend → http://localhost:8501 +``` + +Then start the web app in a second terminal (Node.js 20.9+): + +```bash +cd web +npm install +echo NEXT_PUBLIC_API_URL=http://localhost:8000 > .env.local +npm run dev # → http://localhost:3000 ``` Run the tests with `pytest` (integration tests use the `db` container and are skipped if it isn't running), @@ -296,7 +320,7 @@ and lint with `ruff check .`. ### Deploying your own copy -See [DEPLOY.md](DEPLOY.md): Neon (database), Render (API), Streamlit Community Cloud (web app), GitHub Actions +See [DEPLOY.md](DEPLOY.md): Neon (database), Render (API), Vercel (web app), GitHub Actions (daily data), cron-job.org (keep-awake) and Groq (LLM), all on free tiers. ### Troubleshooting diff --git a/tests/unit/test_llm.py b/tests/unit/test_llm.py index ade26fe..a56227e 100644 --- a/tests/unit/test_llm.py +++ b/tests/unit/test_llm.py @@ -74,6 +74,14 @@ def test_stream_yields_tokens(monkeypatch): assert sent[0]["stream"] is True +def test_stream_decodes_utf8_bytes(monkeypatch): + # Event streams usually carry no charset; the bytes must still be read as UTF-8 + text = "O₂ minimum 0.8 µmol/kg" + line = ("data: " + json.dumps({"choices": [{"delta": {"content": text}}]}, ensure_ascii=False)).encode("utf-8") + install(monkeypatch, [FakeResponse(lines=[line, b"data: [DONE]"])]) + assert "".join(llm.chat_stream("q")) == text + + def test_stream_yields_nothing_without_an_llm(monkeypatch): monkeypatch.setattr(config, "LLM_BASE_URL", "") assert list(llm.chat_stream("q")) == [] diff --git a/web/.env.example b/web/.env.example new file mode 100644 index 0000000..d24b712 --- /dev/null +++ b/web/.env.example @@ -0,0 +1,2 @@ +# The FloatChat API the site talks to (the browser calls it directly) +NEXT_PUBLIC_API_URL=https://floatchat-ai-svjw.onrender.com diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000..d5ccc9f --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +.next/ +out/ +next-env.d.ts +*.tsbuildinfo +.env*.local +.vercel diff --git a/web/app/(site)/compare/page.tsx b/web/app/(site)/compare/page.tsx new file mode 100644 index 0000000..47629b4 --- /dev/null +++ b/web/app/(site)/compare/page.tsx @@ -0,0 +1,32 @@ +import type { Metadata } from "next"; +import { CompareView } from "@/components/compare-view"; +import { getFloats } from "@/lib/api"; + +export const metadata: Metadata = { + title: "Compare floats", + description: "Overlay two Argo floats' trajectories, T-S diagrams and mean profiles.", +}; + +export default async function ComparePage({ searchParams }: { searchParams: Promise<{ floats?: string }> }) { + const { floats: param } = await searchParams; + const floats = await getFloats().catch(() => []); + const ids = floats.sort((a, b) => b.n_profiles - a.n_profiles).map((f) => f.float_id); + const initial = (param ?? "").split(",").filter((id) => ids.includes(id)).slice(0, 2); + // Suggest a contrasting second float: the most active one from another region + if (initial.length === 1) { + const region = floats.find((f) => f.float_id === initial[0])?.region; + const other = floats.find((f) => f.region !== region); + if (other) initial.push(other.float_id); + } + return ( +
+

Compare floats

+

+ Two floats, side by side: where they drifted, the water they sampled and how their profiles differ. +

+
+ +
+
+ ); +} diff --git a/web/app/(site)/floats/[id]/page.tsx b/web/app/(site)/floats/[id]/page.tsx new file mode 100644 index 0000000..6d0ddf4 --- /dev/null +++ b/web/app/(site)/floats/[id]/page.tsx @@ -0,0 +1,60 @@ +import { ArrowLeft } from "@phosphor-icons/react/dist/ssr"; +import type { Metadata } from "next"; +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { FloatViews } from "@/components/float-views"; +import { getFloat, getTrack } from "@/lib/api"; +import { formatDate, formatInt, formatNumber, formatPosition, parseDate } from "@/lib/format"; +import { sensorsOf } from "@/lib/profiles"; + +export const revalidate = 600; + +export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise { + const { id } = await params; + return { title: `Float ${id}`, description: `Trajectory, profiles and T-S diagram of Argo float ${id}.` }; +} + +export default async function FloatPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const [detail, track] = await Promise.all([getFloat(id).catch(() => null), getTrack(id).catch(() => [])]); + if (!detail || !track.length) notFound(); + + const info = detail.float_info; + const latest = [...track].sort((a, b) => parseDate(b.profile_date).getTime() - parseDate(a.profile_date).getTime())[0]; + const facts = [ + { label: "Profiles", value: formatInt(detail.profile_summary.total_profiles) }, + { label: "Depth range", value: `${formatInt(detail.measurement_summary.min_depth)} to ${formatInt(detail.measurement_summary.max_depth)} m` }, + { label: "Mean temperature", value: `${formatNumber(detail.measurement_summary.avg_temp, 1)} °C` }, + { label: "Mean salinity", value: `${formatNumber(detail.measurement_summary.avg_sal, 2)} PSU` }, + ]; + + return ( +
+ + All floats + +
+
+

+ Float {info.float_id} +

+

+ {latest.region}. Last surfaced {formatDate(latest.profile_date)} at {formatPosition(latest.profile_lat, latest.profile_lon)}. + Measures {sensorsOf(track).join(", ")}. +

+
+
+ {facts.map((f) => ( +
+
{f.label}
+
{f.value}
+
+ ))} +
+
+
+ +
+
+ ); +} diff --git a/web/app/(site)/floats/page.tsx b/web/app/(site)/floats/page.tsx new file mode 100644 index 0000000..c2d49a2 --- /dev/null +++ b/web/app/(site)/floats/page.tsx @@ -0,0 +1,31 @@ +import type { Metadata } from "next"; +import { FloatIndex } from "@/components/float-index"; +import { getFloats } from "@/lib/api"; + +export const revalidate = 600; +export const metadata: Metadata = { + title: "Floats", + description: "Every Argo float that reported in the Indian Ocean in the last 12 months.", +}; + +export default async function FloatsPage() { + const floats = await getFloats().catch(() => []); + return ( +
+

Floats

+

+ Every float that reported in the last 12 months. The swatch is its latest surface temperature. Open one to see + where it drifted and what it measured on the way down. +

+
+ {floats.length ? ( + + ) : ( +

+ The float list didn't load. The API may be waking up; refresh in a few seconds. +

+ )} +
+
+ ); +} diff --git a/web/app/(site)/layout.tsx b/web/app/(site)/layout.tsx new file mode 100644 index 0000000..6f7d569 --- /dev/null +++ b/web/app/(site)/layout.tsx @@ -0,0 +1,10 @@ +import { SiteFooter } from "@/components/site-footer"; + +export default function SiteLayout({ children }: { children: React.ReactNode }) { + return ( + <> + {children} + + + ); +} diff --git a/web/app/(site)/page.tsx b/web/app/(site)/page.tsx new file mode 100644 index 0000000..aefddda --- /dev/null +++ b/web/app/(site)/page.tsx @@ -0,0 +1,222 @@ +import { ArrowRight, ArrowUpRight } from "@phosphor-icons/react/dist/ssr"; +import Link from "next/link"; +import { AskBox } from "@/components/ask-box"; +import { Globe } from "@/components/globe"; +import { HowItWorks } from "@/components/how-it-works"; +import { getBenchmark, getFloats, getStats } from "@/lib/api"; +import { BENCHMARK_TABLE_URL } from "@/lib/config"; +import { formatDate, formatInt, formatMs } from "@/lib/format"; +import type { Benchmark, FloatPoint, Stats } from "@/lib/types"; + +export const revalidate = 600; + +const QUESTIONS: { q: string; how: "SQL" | "Search" }[] = [ + { q: "What is the average temperature at different depths in the Arabian Sea?", how: "SQL" }, + { q: "Which floats measured low oxygen in the Arabian Sea?", how: "Search" }, + { q: "How has the average temperature near the equator changed over time?", how: "SQL" }, + { q: "Show me salinity profiles near the equator in March 2026", how: "Search" }, + { q: "Compare chlorophyll levels across zones in the Bay of Bengal", how: "SQL" }, + { q: "How many profiles were recorded in the Arabian Sea?", how: "SQL" }, +]; + +function stages(stats: Stats | null) { + return [ + { + title: "Floats measure the ocean", + body: "Robotic Argo floats drift at 1,000 m, dive to 2,000 m every ten days and rise measuring temperature and salinity. BGC floats add oxygen, chlorophyll, nitrate and pH.", + detail: stats ? `${formatInt(stats.floats)} floats, ${formatInt(stats.bgc_floats)} with oxygen sensors` : "Indian Ocean, last 12 months", + }, + { + title: "A nightly pipeline keeps a year of data", + body: "GitHub Actions pulls new profiles from Ifremer ERDDAP, keeps quality-controlled values, puts every profile on 24 standard depths and writes a one-sentence description of it.", + detail: "QC flags 1-2, 24 standard depths, MiniLM embeddings, Postgres + pgvector", + }, + { + title: "The router reads your question", + body: "An LLM decides whether the question needs statistics or a search, and pulls out the region, month, year and sensor. Keyword rules take over if the LLM is unavailable.", + detail: "gpt-oss-120b on Groq, validated JSON, keyword fallback", + }, + { + title: "SQL or search does the work", + body: "Statistics run as vetted SQL templates, or as LLM-written SQL that is checked, run on a read-only role and repaired from database errors. Descriptions are found by vector search.", + detail: "read-only role, 5 s timeout, up to 2 repairs, exact filtered search", + }, + { + title: "The answer shows its work", + body: "The answer streams in with the chart, the SQL and this run log, so every number can be checked against the query that produced it.", + detail: "Server-Sent Events: step, token, result", + }, + ]; +} + +async function load(fn: () => Promise): Promise { + try { + return await fn(); + } catch { + return null; + } +} + +function StatStrip({ stats }: { stats: Stats }) { + const items = [ + { value: formatInt(stats.floats), label: "active floats" }, + { value: formatInt(stats.profiles), label: "profiles" }, + { value: formatInt(stats.measurements), label: "measurements" }, + { value: formatDate(stats.end_date), label: "newest profile, refreshed nightly" }, + ]; + return ( +
+ {items.map((item) => ( +
+
{item.label}
+
{item.value}
+
+ ))} +
+ ); +} + +function BenchmarkBand({ benchmark }: { benchmark: Benchmark }) { + const s = benchmark.summary; + const pct = (x: number) => `${Math.round(x * 100)}%`; + const metrics = [ + { label: "Routing", value: pct(s.routing_accuracy), note: "SQL or search, and which analysis" }, + { label: "Filters", value: pct(s.filter_accuracy), note: "region, month, year and sensor" }, + { label: "Answer checks", value: pct(s.answer_pass_rate), note: "SQL applies the filters; profiles match" }, + { label: "Median latency", value: s.median_latency_ms ? formatMs(s.median_latency_ms) : "n/a", note: "full answer, live API" }, + ]; + return ( +
+
+

+ {s.passed}/{s.cases} +

+

benchmark questions answered fully correctly

+

+ Thirty fixed questions, re-run against the live API every week. Each is checked for how it was routed, + which filters were understood and whether the answer holds up. +

+ + See every question + +
+
+ {metrics.map((m) => ( +
+
{m.label}
+
{m.value}
+
{m.note}
+
+ ))} +
+
+ ); +} + +const STACK = [ + { name: "Next.js", slug: "nextdotjs" }, + { name: "FastAPI", slug: "fastapi" }, + { name: "PostgreSQL", slug: "postgresql" }, + { name: "Python", slug: "python" }, + { name: "GitHub Actions", slug: "githubactions" }, + { name: "Render", slug: "render" }, + { name: "Vercel", slug: "vercel" }, +]; + +export default async function Home() { + const [stats, floats, benchmark] = await Promise.all([load(getStats), load(getFloats), load(getBenchmark)]); + + return ( + <> +
+
+

+ Ask the Indian Ocean anything. +

+

+ A year of real Argo float data. Ask in plain English and watch FloatChat query, search and explain. +

+
+ +
+ + Or browse the floats + +
+
+ {floats?.length ? ( + + ) : ( +
+ )} +
+
+ + {stats && ( +
+ +
+ )} + +
+

Start with a question

+

+ Statistics become SQL on the database. Questions about particular profiles become a search of their + descriptions. Follow-ups such as “now only the Bay of Bengal” keep the context. +

+
    + {QUESTIONS.map(({ q, how }) => ( +
  • + + {q} + {how} + + +
  • + ))} +
+
+ +
+
+ +
+
+ + {benchmark && ( +
+ +

Last run {benchmark.ran_at}.

+
+ )} + +
+
+ {STACK.map((tool) => ( + // eslint-disable-next-line @next/next/no-img-element -- small SVG logos from Simple Icons + {tool.name} + ))} +
+
+ +
+
+

Ask your first question.

+ +
+
+ + ); +} diff --git a/web/app/chat/page.tsx b/web/app/chat/page.tsx new file mode 100644 index 0000000..5f457cd --- /dev/null +++ b/web/app/chat/page.tsx @@ -0,0 +1,21 @@ +import type { Metadata } from "next"; +import { Chat } from "@/components/chat"; +import { getStats } from "@/lib/api"; +import { formatDate } from "@/lib/format"; + +export const metadata: Metadata = { + title: "Ask", + description: "Ask questions about Argo ocean-float data and watch every step of the answer.", +}; + +export default async function ChatPage({ searchParams }: { searchParams: Promise<{ q?: string }> }) { + const { q } = await searchParams; + let coverage = "Argo floats in the Indian Ocean, last 12 months. Answers can be wrong; the run log shows how each was made."; + try { + const stats = await getStats(); + coverage = `Argo floats in the Indian Ocean, ${formatDate(stats.start_date)} to ${formatDate(stats.end_date)}. The run log shows how each answer was made.`; + } catch { + // keep the generic line if the API is asleep + } + return ; +} diff --git a/web/app/error.tsx b/web/app/error.tsx new file mode 100644 index 0000000..7039475 --- /dev/null +++ b/web/app/error.tsx @@ -0,0 +1,19 @@ +"use client"; + +export default function ErrorPage({ reset }: { error: Error & { digest?: string }; reset: () => void }) { + return ( +
+

The data didn't load.

+

+ The FloatChat API runs on free hosting and may be waking up. Give it a few seconds and try again. +

+ +
+ ); +} diff --git a/web/app/globals.css b/web/app/globals.css new file mode 100644 index 0000000..ce7bf97 --- /dev/null +++ b/web/app/globals.css @@ -0,0 +1,230 @@ +@import "tailwindcss"; + +@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *)); + +/* Design tokens. Graphite neutrals, one accent (marine safety orange). Data colours live in lib/colors.ts. */ +:root { + --bg: #f3f4f2; + --surface: #ffffff; + --surface-2: #e9ebe8; + --line: rgb(17 18 20 / 0.09); + --line-strong: rgb(17 18 20 / 0.17); + --text: #111214; + --text-2: #464b52; + --muted: #676c74; + --accent: #d1430d; + --accent-ink: #ffffff; + --accent-soft: rgb(209 67 13 / 0.1); + --ok: #1f7a4d; + --warn: #9a5b00; + --shadow: 0 1px 2px rgb(17 18 20 / 0.05), 0 8px 24px -12px rgb(17 18 20 / 0.12); + --land: #cdd1cb; + --water: #e9ece8; + --graticule: rgb(17 18 20 / 0.06); + color-scheme: light; +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) { + --bg: #0d0e10; + --surface: #141619; + --surface-2: #1c1f23; + --line: rgb(255 255 255 / 0.07); + --line-strong: rgb(255 255 255 / 0.14); + --text: #ecedee; + --text-2: #a5aab2; + --muted: #80858e; + --accent: #ff5b1f; + --accent-ink: #0d0e10; + --accent-soft: rgb(255 91 31 / 0.12); + --ok: #4cc38a; + --warn: #f0b429; + --shadow: 0 1px 2px rgb(0 0 0 / 0.4), 0 12px 32px -16px rgb(0 0 0 / 0.6); + --land: #2f343a; + --water: #0f1113; + --graticule: rgb(255 255 255 / 0.05); + color-scheme: dark; + } +} + +:root[data-theme="dark"] { + --bg: #0d0e10; + --surface: #141619; + --surface-2: #1c1f23; + --line: rgb(255 255 255 / 0.07); + --line-strong: rgb(255 255 255 / 0.14); + --text: #ecedee; + --text-2: #a5aab2; + --muted: #80858e; + --accent: #ff5b1f; + --accent-ink: #0d0e10; + --accent-soft: rgb(255 91 31 / 0.12); + --ok: #4cc38a; + --warn: #f0b429; + --shadow: 0 1px 2px rgb(0 0 0 / 0.4), 0 12px 32px -16px rgb(0 0 0 / 0.6); + --land: #2f343a; + --water: #0f1113; + --graticule: rgb(255 255 255 / 0.05); + color-scheme: dark; +} + +@theme inline { + --color-bg: var(--bg); + --color-surface: var(--surface); + --color-surface-2: var(--surface-2); + --color-line: var(--line); + --color-line-strong: var(--line-strong); + --color-ink: var(--text); + --color-ink-2: var(--text-2); + --color-muted: var(--muted); + --color-accent: var(--accent); + --color-accent-ink: var(--accent-ink); + --color-accent-soft: var(--accent-soft); + --color-ok: var(--ok); + --color-warn: var(--warn); + --font-sans: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif; + --font-mono: var(--font-geist-mono), ui-monospace, "SFMono-Regular", monospace; + --shadow-card: var(--shadow); + --radius-card: 14px; +} + +html { + background: var(--bg); + color: var(--text); + -webkit-text-size-adjust: 100%; + scroll-behavior: smooth; +} + +body { + background: var(--bg); + color: var(--text); + font-family: var(--font-sans); + font-feature-settings: "ss01", "cv11"; + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; +} + +::selection { + background: var(--accent-soft); + color: var(--text); +} + +:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + border-radius: 6px; +} + +.tabular { + font-variant-numeric: tabular-nums; +} + +/* Frosted header with a solid fallback for people who ask for less transparency */ +.glass { + background: color-mix(in srgb, var(--bg) 78%, transparent); + backdrop-filter: blur(14px) saturate(140%); + -webkit-backdrop-filter: blur(14px) saturate(140%); +} +@media (prefers-reduced-transparency: reduce) { + .glass { + background: var(--bg); + backdrop-filter: none; + -webkit-backdrop-filter: none; + } +} + +/* Answer text rendered from the model's Markdown */ +.answer-md { + line-height: 1.65; + color: var(--text); +} +.answer-md > * + * { + margin-top: 0.7em; +} +.answer-md strong { + font-weight: 600; +} +.answer-md ul, +.answer-md ol { + padding-left: 1.15em; +} +.answer-md ul { + list-style: disc; +} +.answer-md ol { + list-style: decimal; +} +.answer-md li + li { + margin-top: 0.25em; +} +.answer-md li::marker { + color: var(--muted); +} +.answer-md code { + font-family: var(--font-mono); + font-size: 0.88em; + background: var(--surface-2); + padding: 0.1em 0.35em; + border-radius: 5px; +} +.answer-md table { + width: 100%; + font-size: 0.9em; + border-collapse: collapse; + font-variant-numeric: tabular-nums; +} +.answer-md th, +.answer-md td { + text-align: left; + padding: 0.35em 0.6em; + border-bottom: 1px solid var(--line); +} +.answer-md h1, +.answer-md h2, +.answer-md h3 { + font-weight: 600; + letter-spacing: -0.01em; +} + +/* Observable Plot charts inherit the theme */ +.plot-frame svg { + overflow: visible; + font-family: var(--font-sans); + font-size: 11px; + color: var(--text-2); +} +.plot-frame [aria-label="tip"] { + color: var(--text); +} + +/* Typing caret at the end of a streaming answer */ +.caret::after, +.answer-md.is-streaming > :last-child:not(ul, ol, table)::after, +.answer-md.is-streaming > :is(ul, ol):last-child > li:last-child::after { + content: ""; + display: inline-block; + width: 0.5em; + height: 1em; + margin-left: 2px; + vertical-align: -0.12em; + background: var(--accent); + animation: blink 1s steps(2, start) infinite; +} +@keyframes blink { + to { + visibility: hidden; + } +} + +@media (prefers-reduced-motion: reduce) { + html { + scroll-behavior: auto; + } + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +} diff --git a/web/app/icon.svg b/web/app/icon.svg new file mode 100644 index 0000000..cd82c6e --- /dev/null +++ b/web/app/icon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/web/app/layout.tsx b/web/app/layout.tsx new file mode 100644 index 0000000..79b0c9f --- /dev/null +++ b/web/app/layout.tsx @@ -0,0 +1,43 @@ +import type { Metadata, Viewport } from "next"; +import { GeistMono } from "geist/font/mono"; +import { GeistSans } from "geist/font/sans"; +import { SiteHeader } from "@/components/site-header"; +import "./globals.css"; + +export const metadata: Metadata = { + metadataBase: new URL("https://floatchat.vercel.app"), + title: { default: "FloatChat: ask the Indian Ocean", template: "%s · FloatChat" }, + description: + "Ask questions about a year of real Argo ocean-float data in plain English. FloatChat writes the SQL or searches " + + "the profiles, shows every step, and draws the charts.", + openGraph: { title: "FloatChat", description: "Ask the Indian Ocean in plain English.", type: "website" }, +}; + +export const viewport: Viewport = { + themeColor: [ + { media: "(prefers-color-scheme: light)", color: "#f3f4f2" }, + { media: "(prefers-color-scheme: dark)", color: "#0d0e10" }, + ], +}; + +// Applies a saved theme before first paint so there is no flash of the wrong theme +const themeScript = `try{var t=localStorage.getItem("theme");if(t==="light"||t==="dark")document.documentElement.dataset.theme=t}catch(e){}`; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + +