Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@
},
"navbar": {
"links": [
{
"label": "Demo",
"href": "https://shipvoice.dev/demo"
},
{
"label": "Discord",
"href": "https://discord.gg/ysFaF4uSB"
Expand Down
1 change: 1 addition & 0 deletions frontend/.dockerignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
node_modules
dist
dist-demo
coverage
.env
.env.local
Expand Down
25 changes: 25 additions & 0 deletions frontend/.env.demo
Original file line number Diff line number Diff line change
@@ -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
11 changes: 7 additions & 4 deletions frontend/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/*
Expand Down
17 changes: 17 additions & 0 deletions frontend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +46 to +56

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Make the documented preview URL match the configured base path.

Line 46 states that the preview is served at shipvoice.dev/demo. frontend/.env.demo sets VITE_DEMO_BASE=/demo/console/. If the site serves the documented path, the bundle requests assets from a different prefix.

Update the documentation and deployment link to /demo/console/, or change VITE_DEMO_BASE and all deployment configuration together.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/README.md` around lines 46 - 56, Update the README’s documented demo
URL and any deployment link in the surrounding documentation to use the
configured /demo/console/ base path, keeping it consistent with VITE_DEMO_BASE
in .env.demo. Do not change the application configuration.

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
Expand Down
1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
29 changes: 29 additions & 0 deletions frontend/src/api-error.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
40 changes: 10 additions & 30 deletions frontend/src/api.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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.
Expand Down Expand Up @@ -74,13 +61,6 @@ async function get<T>(path: string, what: string): Promise<T> {
return (await res.json()) as T;
}

export interface CallListParams {
limit?: number;
offset?: number;
channel?: string;
status?: string;
}

export async function listCalls(
params: CallListParams = {},
): Promise<CallListResponse> {
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/components/AppShell.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -50,6 +52,7 @@ export function AppShell() {
<div className="sv-console fr">
<Rail counts={{ agents: agentCount, calls: callCount }} />
<div className="cv">
{DEMO && <DemoBar />}
<div className="bd">
<Outlet />
</div>
Expand Down
12 changes: 11 additions & 1 deletion frontend/src/components/Rail.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -46,7 +56,7 @@ export function Rail({
return (
<aside className="rail">
<div className="brand">
<img src="/logo-boat.svg" alt="" width={20} height={20} />
<img src={MARK} alt="" width={20} height={20} />
<span>ShipVoice</span>
</div>

Expand Down
37 changes: 30 additions & 7 deletions frontend/src/components/TestCall.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { AgentSessionProvider } from "@/components/agents-ui/agent-session-provi
import { useInputControls } from "@/hooks/agents-ui/use-agent-control-bar";
import { AGENT_NAME, tokenSource } from "@/lib/token-source";
import { listAgents } from "../api";
import { DEMO } from "../demo/flag";
import type { AgentSummary } from "../types";
import { Ann, TopBar } from "./AppShell";
import { Badge, Button, Panel } from "./ds";
Expand All @@ -26,6 +27,17 @@ import { Badge, Button, Panel } from "./ds";

const NO_JOIN_MS = 10_000;

/**
* Why the preview draws this screen and will not run it.
*
* A test call opens a real WebRTC room against a real LiveKit project and takes
* a real microphone. None of that can be faked without inventing a conversation
* that never happened, so the preview shows the surface, disables the button,
* and says what is missing. There is no scripted transcript here.
*/
const DEMO_NO_CALL =
"A test call runs against your own LiveKit project and your own microphone, so it does not run in this preview. Clone the repo, add your keys, and this button places a real call to your worker.";

const MONO = { fontFamily: "var(--font-mono)", color: "var(--text-primary)" };

function mmss(ms: number): string {
Expand Down Expand Up @@ -221,9 +233,9 @@ function CallSurface({
>
{!connected ? (
<p className="fnt" style={{ font: "var(--type-body-sm)", margin: 0 }}>
Talk to the agent from this browser. Your microphone is used only
while the call is connected, and the call runs through the same
LiveKit room a real caller would land in.
{DEMO
? DEMO_NO_CALL
: "Talk to the agent from this browser. Your microphone is used only while the call is connected, and the call runs through the same LiveKit room a real caller would land in."}
</p>
) : messages.length === 0 ? (
<p className="fnt" style={{ font: "var(--type-body-sm)", margin: 0 }}>
Expand Down Expand Up @@ -270,7 +282,12 @@ function CallSurface({
greyed Evaluations rail item is where that absence is stated. */}
<div style={{ marginTop: "auto", display: "flex", gap: 8 }}>
{!connected && (
<Button variant="primary" onClick={onStart} disabled={connecting}>
<Button
variant="primary"
onClick={onStart}
disabled={connecting || DEMO}
title={DEMO ? DEMO_NO_CALL : undefined}
>
{connecting ? "Connecting…" : "Start test call"}
</Button>
)}
Expand Down Expand Up @@ -338,6 +355,10 @@ export function TestCall() {
}, []);

const start = (): void => {
// Belt and braces with the disabled button above. The preview is a static
// page on a public host, and it must not open a socket from there under any
// path, including one a browser extension or a stale bundle finds.
if (DEMO) return;
setError(null);
void session
.start()
Expand Down Expand Up @@ -489,10 +510,12 @@ export function TestCall() {
cost, so there is no figure to show for either.
</p>
<div style={{ marginTop: 16 }}>
{/* Present tense in the preview would claim this page places a
real call, directly under the copy saying it does not. */}
<Ann>
A test call runs on your own provider keys and your own LiveKit
project. It is a real call to the same worker a caller reaches,
and nothing here bills it to anyone.
{DEMO
? "In your own clone, a test call runs on your provider keys and your LiveKit project, and reaches the same worker a caller does. Nothing here does any of that."
: "A test call runs on your own provider keys and your own LiveKit project. It is a real call to the same worker a caller reaches, and nothing here bills it to anyone."}
</Ann>
</div>
</div>
Expand Down
11 changes: 9 additions & 2 deletions frontend/src/components/console.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,16 @@ describe("Rail", () => {
});


it("uses the real ShipVoice mark", () => {
it("uses the real ShipVoice mark, resolved against the bundle's base", () => {
// Against BASE_URL rather than the literal "/logo-boat.svg": a build with a
// base, which is what the /demo preview is, serves it from under that base
// and a bare absolute path 404s there.
const { container } = withRouter(<Rail />);
expect(container.querySelector('img[src="/logo-boat.svg"]')).not.toBeNull();
const mark = container.querySelector<HTMLImageElement>("img");
expect(mark).not.toBeNull();
expect(mark?.getAttribute("src")).toBe(
`${import.meta.env.BASE_URL}logo-boat.svg`,
);
});
});

Expand Down
41 changes: 41 additions & 0 deletions frontend/src/console.css
Original file line number Diff line number Diff line change
Expand Up @@ -1295,3 +1295,44 @@
vertical-align: middle;
cursor: help;
}

/* ---------- preview banner ---------- */
/* Only in the demo build (VITE_DEMO), where the console runs on fixtures. It
sits above .bd rather than inside it, so it is on every screen and does not
scroll away, and it has no dismiss control on purpose: a banner people can
close is a banner people close, and then sample figures start reading as a
deployment's own. */
.sv-console .demo-bar {
flex: none;
display: flex;
align-items: center;
gap: 12px;
padding: 9px var(--pad-frame);
border-bottom: 1px solid var(--border-default);
background: var(--surface-sunken);
font: var(--type-body-sm);
color: var(--text-secondary);
flex-wrap: wrap;
}

.sv-console .demo-tag {
flex: none;
padding: 2px 7px;
border-radius: var(--sv-radius-sm);
background: var(--accent-quiet);
color: var(--accent-quiet-text);
font: var(--type-label);
letter-spacing: var(--tracking-label);
text-transform: uppercase;
}

.sv-console .demo-say {
min-width: 0;
flex: 1 1 320px;
}

.sv-console .demo-bar a {
flex: none;
margin-left: auto;
white-space: nowrap;
}
Loading
Loading