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 (