From 8f63e90fdc9cef7459d565867ada0dced2d368a6 Mon Sep 17 00:00:00 2001 From: Mahimai Raja J Date: Wed, 12 Aug 2026 16:41:26 -0400 Subject: [PATCH] feat(frontend): a backendless demo build of the console 'pnpm build:demo' produces the console running on fixtures, for the preview at shipvoice.dev/demo. It is the real console: vite.config resolves src/api.ts to src/demo/fixtures.ts for that build only, so no page carries an 'if (DEMO)' around a fetch, the preview cannot drift from what ships, and the network client is not merely branched around, it is absent from the bundle. Forty seeded calls over about seven days. Every derived figure on the Overview is folded out of that one list by the rules the backend uses, so a reader who counts the rows and compares them with 'Calls today' gets the same answer. Writes are refused rather than faked. The console's success copy is written for a real deployment, so a fake write would print 'Written to agent/prompts/instructions.md. The next call picks it up' over a page with no disk. The test call renders and is disabled: it needs a real LiveKit project and a real microphone, and a scripted transcript would be a conversation that never happened. Proved offline two ways: no string unique to api.ts survives into dist-demo, and driving the built bundle in a browser produces four requests, all for its own assets. Fixed before this shipped: - The alias matched only './api' and '../api'. A later 'from "@/api"' would have shipped the real fetch client into a public demo with every check still green. The whole no-network guarantee rested on that regex. - VITE_DEMO_BASE was '/demo/' while the site serves the bundle from '/demo/console/', so the published preview would have loaded no JavaScript at all. - 'Calls today' counted the UTC day while the log beside it renders local timestamps, so the figure contradicted the rows underneath it for every visitor outside UTC. Its test asserted UTC too, so it agreed with the bug. - The test call's footer still claimed in the present tense that this is a real call, directly under the new copy saying it does not run here. - The preview banner now names the money strip. Those figures are the paid console's and are marked by a red dot you have to hover, which is not a disclosure on the first screen a stranger sees. docs: the docs navbar links to the demo. --- docs/docs.json | 4 + frontend/.dockerignore | 1 + frontend/.env.demo | 25 ++ frontend/.gitignore | 11 +- frontend/README.md | 17 + frontend/package.json | 1 + frontend/src/api-error.ts | 29 ++ frontend/src/api.ts | 40 +- frontend/src/components/AppShell.tsx | 3 + frontend/src/components/Rail.tsx | 12 +- frontend/src/components/TestCall.tsx | 37 +- frontend/src/components/console.test.tsx | 11 +- frontend/src/console.css | 41 ++ frontend/src/demo/DemoBar.tsx | 34 ++ frontend/src/demo/data.ts | 492 +++++++++++++++++++++++ frontend/src/demo/fixtures.test.ts | 349 ++++++++++++++++ frontend/src/demo/fixtures.ts | 197 +++++++++ frontend/src/demo/flag.ts | 13 + frontend/src/demo/router.ts | 12 + frontend/src/lib/token-source.ts | 24 +- frontend/src/main.tsx | 6 +- frontend/src/pages/CallDetail.tsx | 7 +- frontend/src/pages/Deployment.tsx | 11 +- frontend/src/types.ts | 12 + frontend/src/vite-env.d.ts | 9 + frontend/vite.config.ts | 92 +++-- 26 files changed, 1411 insertions(+), 79 deletions(-) create mode 100644 frontend/.env.demo create mode 100644 frontend/src/api-error.ts create mode 100644 frontend/src/demo/DemoBar.tsx create mode 100644 frontend/src/demo/data.ts create mode 100644 frontend/src/demo/fixtures.test.ts create mode 100644 frontend/src/demo/fixtures.ts create mode 100644 frontend/src/demo/flag.ts create mode 100644 frontend/src/demo/router.ts diff --git a/docs/docs.json b/docs/docs.json index dfc5e31..7bc698b 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -40,6 +40,10 @@ }, "navbar": { "links": [ + { + "label": "Demo", + "href": "https://shipvoice.dev/demo" + }, { "label": "Discord", "href": "https://discord.gg/ysFaF4uSB" diff --git a/frontend/.dockerignore b/frontend/.dockerignore index 560d455..74f32b5 100644 --- a/frontend/.dockerignore +++ b/frontend/.dockerignore @@ -1,5 +1,6 @@ node_modules dist +dist-demo coverage .env .env.local diff --git a/frontend/.env.demo b/frontend/.env.demo new file mode 100644 index 0000000..4ebf46d --- /dev/null +++ b/frontend/.env.demo @@ -0,0 +1,25 @@ +# The public preview of the console: `pnpm build:demo`, which is +# `vite build --mode demo`. Loaded only in that mode, so `pnpm dev` and +# `pnpm build` are untouched by everything here. +# +# Committed on purpose, and it holds no secret: the preview has no backend, no +# database and no LiveKit project. Nothing in the built bundle makes a request. + +# Swaps src/api.ts for src/demo/fixtures.ts (see vite.config.ts) and turns on +# the hash router, the preview banner, and the disabled test call. +VITE_DEMO=true + +# Where the bundle is served from. Assets resolve against it, so the preview can +# be moved without touching code. +VITE_DEMO_BASE=/demo/console/ + +# Pinned, so a developer's own frontend/.env cannot leak into a published +# preview: the agent name has to match the fixture agent, or the console draws a +# "names differ" warning about a worker that does not exist. +# +# VITE_TOKEN_ENDPOINT and VITE_API_BASE_URL are deliberately NOT set here. Both +# default to a localhost address, and neither default survives into this build: +# src/api.ts is not resolved at all, and src/lib/token-source.ts takes its +# refusing branch. Leaving them unset keeps "no localhost in dist-demo" a true +# test of that, instead of a fact about this file. +VITE_AGENT_NAME=assistant diff --git a/frontend/.gitignore b/frontend/.gitignore index f1507ff..59f6a53 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -10,16 +10,19 @@ lerna-debug.log* node_modules dist dist-ssr +# The public preview build (pnpm build:demo). +dist-demo *.local # Test coverage (vitest) coverage -# Env (copy .env -.env.* -!.env.example.example -> .env) +# Env (copy .env.example -> .env). Two files here are committed and carry no +# secret: the example, and .env.demo, which configures the preview build. .env -.env.local +.env.* +!.env.example +!.env.demo # Editor directories and files .vscode/* diff --git a/frontend/README.md b/frontend/README.md index cf3d176..b407822 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -36,10 +36,27 @@ as editable source. Swap the visualizer by importing a different ```bash pnpm dev # dev server pnpm build # production build -> dist/ +pnpm build:demo # public preview build -> dist-demo/ pnpm run lint # eslint pnpm test # vitest ``` +## The preview build + +`pnpm build:demo` produces the console at shipvoice.dev/demo. It is this same +app, with one substitution: `vite.config.ts` resolves `src/api.ts` to +`src/demo/fixtures.ts`, so every read comes from a fixed sample deployment and +every write is refused. Nothing in that bundle makes a request. + +Fixtures rather than a live deployment, because the backend has no +authentication on any route: a public one would let anyone rewrite the agent's +prompt, repoint the LiveKit project, and spend your provider credits. + +Settings live in `.env.demo`. `VITE_DEMO_BASE` moves it off `/demo/`, and the +router switches to hashes so a deep link needs no server rewrite. A test call +needs a real LiveKit project and a microphone, so that screen renders with its +button disabled rather than replaying a conversation that never happened. + ## Deploy Static build (`dist/`) → Cloudflare Pages. Set `VITE_TOKEN_ENDPOINT` to the prod diff --git a/frontend/package.json b/frontend/package.json index 028e717..83454ca 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,6 +7,7 @@ "scripts": { "dev": "vite", "build": "tsc -b && vite build", + "build:demo": "tsc -b && vite build --mode demo", "lint": "eslint .", "preview": "vite preview", "test": "vitest run", diff --git a/frontend/src/api-error.ts b/frontend/src/api-error.ts new file mode 100644 index 0000000..1fd5bb4 --- /dev/null +++ b/frontend/src/api-error.ts @@ -0,0 +1,29 @@ +/** + * The one error type the console branches on. + * + * It lives apart from api.ts because it carries no URL and makes no request, + * and both the real client and the demo build's fixtures raise it. Two copies + * of the class would make `e instanceof ApiError` answer false across the seam, + * which is the check every page uses to tell a refusal from an outage. + */ +export class ApiError extends Error { + readonly status: number; + + constructor(status: number, message: string) { + super(message); + this.name = "ApiError"; + this.status = status; + } + + get isForbidden(): boolean { + return this.status === 401 || this.status === 403; + } + + get isUnreachable(): boolean { + return this.status === 0 || this.status >= 500; + } + + get isMissing(): boolean { + return this.status === 404; + } +} diff --git a/frontend/src/api.ts b/frontend/src/api.ts index d806962..b1c51ea 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -1,8 +1,12 @@ -// The only module that knows the backend's base URL. +// The only module that knows the backend's base URL, and the console's one seam +// to it. The demo build (VITE_DEMO) resolves this specifier to +// src/demo/fixtures.ts instead, so nothing below ships in that bundle. See the +// alias in vite.config.ts. import type { AgentListResponse, AgentPromptRead, CallDetailResponse, + CallListParams, CallListResponse, CallOverviewResponse, CallRollupResponse, @@ -13,30 +17,13 @@ import type { RoomTokenResponse, } from "./types"; -export const API_BASE = - import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000"; - -export class ApiError extends Error { - readonly status: number; - - constructor(status: number, message: string) { - super(message); - this.name = "ApiError"; - this.status = status; - } - - get isForbidden(): boolean { - return this.status === 401 || this.status === 403; - } +export { ApiError } from "./api-error"; +export type { CallListParams } from "./types"; - get isUnreachable(): boolean { - return this.status === 0 || this.status >= 500; - } +import { ApiError } from "./api-error"; - get isMissing(): boolean { - return this.status === 404; - } -} +export const API_BASE = + import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000"; /** * Turn a non-2xx into an ApiError. @@ -74,13 +61,6 @@ async function get(path: string, what: string): Promise { return (await res.json()) as T; } -export interface CallListParams { - limit?: number; - offset?: number; - channel?: string; - status?: string; -} - export async function listCalls( params: CallListParams = {}, ): Promise { diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx index 53716c5..7feaa30 100644 --- a/frontend/src/components/AppShell.tsx +++ b/frontend/src/components/AppShell.tsx @@ -1,6 +1,8 @@ import { useEffect, useState, type ReactNode } from "react"; import { Link, Outlet } from "react-router"; import { getLiveKit, listAgents, listCalls } from "../api"; +import { DemoBar } from "../demo/DemoBar"; +import { DEMO } from "../demo/flag"; import { Rail } from "./Rail"; import { LiveEventsBar } from "./ds"; @@ -50,6 +52,7 @@ export function AppShell() {
+ {DEMO && }
diff --git a/frontend/src/components/Rail.tsx b/frontend/src/components/Rail.tsx index 38ede89..b0dc9a6 100644 --- a/frontend/src/components/Rail.tsx +++ b/frontend/src/components/Rail.tsx @@ -1,5 +1,15 @@ import { NavLink } from "react-router"; +/** + * The mark, resolved against wherever this bundle is served from. + * + * It lives in public/, so Vite copies it verbatim and does not rewrite the + * reference the way it rewrites an imported asset. A bare "/logo-boat.svg" + * therefore 404s in any build with a base, which is what the /demo preview is. + * BASE_URL is "/" for the normal build and carries its own trailing slash. + */ +const MARK = `${import.meta.env.BASE_URL}logo-boat.svg`; + type Item = { label: string; to?: string; @@ -46,7 +56,7 @@ export function Rail({ return (