AI Assistant
A scripted demo, a typed adapter, and a documented Worker for connecting a real model.
No API keys ship
/ai runs on a scripted adapter: canned answers matched by keyword and streamed on a timer. Nothing calls a provider, so there is no key to leak, no spend to accrue on a public demo, and no CORS or CSP exception needed. It is also deterministic, which is what makes the page screenshot-able and testable.
Under prefers-reduced-motion the scripted adapter yields whole responses instead of streaming them — a token-by-token reveal is animation, and someone who has asked for less of it should not have to watch text type itself.
The adapter interface
One interface, so the canned and the real path are indistinguishable to the UI.
export interface AIAdapter {
/** Offered in the model picker. The first is the default. */
readonly models: readonly AIModel[];
send(request: AIRequest, signal?: AbortSignal): AsyncIterable<AIStreamEvent>;
}
// Events are a discriminated union, so a consumer cannot forget a case.
type AIStreamEvent =
| { type: "text"; delta: string } // additive — concatenate the deltas
| { type: "tool"; call: AIToolCall }
| { type: "citation"; citation: AICitation }
| { type: "error"; message: string };useAIChat consumes that and owns the transcript, the streaming state, stop, regenerate and copy. Swapping adapters is one line.
signal is not optional in practice: the stop button aborts it, and an adapter that ignores cancellation leaves a stopped response still arriving. Throwing AbortError on abort is correct — useAIChat treats it as a stop rather than a failure.
Connecting a real model
The key must never reach the browser, so it lives in a Worker that proxies the provider. examples/ai-worker/ is a complete one.
cd examples/ai-worker
npx wrangler secret put OPENAI_API_KEY # stored in Cloudflare, never in the repo
npx wrangler deploy
# → https://apex-ai-worker.<you>.workers.devThen point the provider at it:
// src/lib/ai/provider.ts
// There is no adapter factory to call — an adapter is a plain object, which is
// the whole point of the interface being this small.
import type { AIAdapter } from "@dashboardpack/core/lib/ai/types";
export const aiAdapter: AIAdapter = {
models: [{ id: "gpt-4o", label: "GPT-4o" }],
async *send(request, signal) {
const response = await fetch(process.env.NEXT_PUBLIC_AI_ENDPOINT!, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(request),
signal, // so the stop button actually stops it
});
for await (const event of readServerSentEvents(response)) {
yield { type: "text", delta: event.delta };
}
},
};The Worker is deliberately .js, not .ts. This project's tsconfig includes **/*.ts project-wide, so a worker.ts would be typechecked with DOM libs and no @cloudflare/workers-types, failing npm run verify for everyone who never deploys it.
Rendering model output safely
The markdown renderer never touches HTML — no dangerouslySetInnerHTML, no sanitiser to keep up to date. It parses a markdown subset into React elements, so an injection is not blocked, it is unrepresentable. Model output is untrusted input by definition: it can be steered by anything in the context, including a document a user uploaded.
The transcript is role="log", not aria-live. A live region announces every token as it streams, which reads a reply out one fragment at a time; a log lets a screen reader read the finished message on its own terms.
Usage and cost
/ai/usage shows request volume, token spend and a latency histogram with p50, p95 and p99 markers. The percentiles are computed from the bucket counts rather than hardcoded, so they cannot drift from the bars they annotate — which is the standard failure of a histogram with stated percentiles.