From d3bfd2094f8ae208ccbd4e036c08fba2edafa301 Mon Sep 17 00:00:00 2001 From: Mahimai Raja J Date: Sat, 8 Aug 2026 12:24:13 -0400 Subject: [PATCH 01/28] fix: a fresh clone can actually follow the manual quickstart agent/.gitignore ignored '.env.*', which matches '.env.example', so the file was never committed and 'cd agent && cp .env.example .env' failed on a clean clone. Narrow the rule and commit the file. Also drop the leftover placeholder PROJECT_NAME, which was the title every buyer saw on /docs after the compose quickstart, and add setup.json to the ignore list ahead of the /setup command that writes it. --- .gitignore | 4 ++++ agent/.env.example | 19 +++++++++++++++++++ agent/.gitignore | 3 +++ backend/src/core/config.py | 2 +- 4 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 agent/.env.example diff --git a/.gitignore b/.gitignore index e7f0f3c..1a5359c 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,7 @@ docs/superpowers # Root env (copy .env.example -> .env) .env .env.local + +# Written by '/setup': holds the non-secret setup answers. The command deletes +# it on a successful run; this is the backstop. +setup.json diff --git a/agent/.env.example b/agent/.env.example new file mode 100644 index 0000000..40c109c --- /dev/null +++ b/agent/.env.example @@ -0,0 +1,19 @@ +# LiveKit (MUST match the backend's credentials) +LIVEKIT_URL=wss://your-project.livekit.cloud +LIVEKIT_API_KEY= +LIVEKIT_API_SECRET= + +# LLM +OPENAI_API_KEY= + +# STT +DEEPGRAM_API_KEY= + +# TTS +CARTESIA_API_KEY= + +# Agent identity. MUST match the agent_name the frontend requests in room_config. +AGENT_NAME=assistant + +# Optional error tracking +# SENTRY_DSN= diff --git a/agent/.gitignore b/agent/.gitignore index 27a6fcd..ca03912 100644 --- a/agent/.gitignore +++ b/agent/.gitignore @@ -1,5 +1,8 @@ .env .env.* +# '.env.*' matches '.env.example' too, which silently kept the file out of the +# repo and broke the README's 'cp .env.example .env' on a fresh clone. +!.env.example .venv/ __pycache__/ *.pyc diff --git a/backend/src/core/config.py b/backend/src/core/config.py index 90f3168..c270d3f 100644 --- a/backend/src/core/config.py +++ b/backend/src/core/config.py @@ -83,7 +83,7 @@ class Config(BaseSettings): MCP_STR: str = "/mcp" MCP_SERVER_URL: str = "http://127.0.0.1:8000/mcp" - PROJECT_NAME: str = "Mahimai's - - -" + PROJECT_NAME: str = "ShipVoice" # CORS: comma-separated origins. Empty means allow all ("*"). CORS_ORIGINS_STR: str | None = "" From 95be7c14cb25fdff877983574aeb504b9878b8cd Mon Sep 17 00:00:00 2001 From: Mahimai Raja J Date: Sat, 8 Aug 2026 12:27:38 -0400 Subject: [PATCH 02/28] docs: say only what ShipVoice Pro actually ships, and credit the vendored code The cross-sell block claimed a template count, recordings, an outbound-caller template, a Render deploy target, and a task-scaffolder subagent. None of those exist in Pro. It also said Pro sits "on top of this core", which is the opposite of the truth: Pro is a separate repo that shares the stack, not a dependency. Rewrite it around what is actually built, and name the paid product ShipVoice Pro throughout so it stops colliding with this repo's own name. Also add THIRD_PARTY_NOTICES.md. Twelve vendored registry components were sitting under a blanket MIT that is not ours to grant. shadcn is MIT and AI Elements is Apache 2.0; the LiveKit Agents UI registry declares no licence at all, so that is recorded as unresolved rather than guessed. --- LICENSE | 11 ++++++++ README.md | 59 +++++++++++++++++++++++------------------- THIRD_PARTY_NOTICES.md | 50 +++++++++++++++++++++++++++++++++++ 3 files changed, 93 insertions(+), 27 deletions(-) create mode 100644 THIRD_PARTY_NOTICES.md diff --git a/LICENSE b/LICENSE index 8f6f22e..7d2d518 100644 --- a/LICENSE +++ b/LICENSE @@ -19,3 +19,14 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +--- + +Scope of the grant above. + +Parts of this repository are third-party code vendored from public component +registries. Those files are licensed by their upstream authors, not by the +notice above. They are listed in THIRD_PARTY_NOTICES.md. + +Images under assets/ that depict ShipVoice Pro are included for comparison and +are not covered by the grant above. They remain the property of the author. diff --git a/README.md b/README.md index 33f95f9..9ddc475 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ - LiveKit Voice AI Starter + ShipVoice -

LiveKit Voice AI Starter

+

ShipVoice

Talk to an AI agent in your browser, in minutes.
@@ -14,7 +14,6 @@

- LiveKit Voice First Python @@ -35,10 +34,11 @@ Most voice-AI demos are a single script. This is the whole loop, structured the way you'd actually ship it, and split into three pieces you can run, deploy, and swap independently. -> This gets you a working voice agent you own. To go from an idea to a -> monetized product in an afternoon, see [ShipVoice](https://shipvoice.dev): an -> AI engineer (subagents that scaffold your agent), 10 templates, per-minute -> Stripe billing, auth, a dashboard, and one-command deploy, on top of this core. +> This gets you a voice agent you own. Turning one into a product you charge for +> is a separate problem, and it is what [ShipVoice Pro](https://shipvoice.dev) +> is: per-call cost metered by provider, per-minute Stripe billing, auth with an +> entitlement gate, compliance gates that fail closed, and a console that shows +> what every call cost you. It is its own repository, not a plugin for this one. ## What's inside @@ -124,41 +124,46 @@ Open `http://localhost:5173`, click **Start conversation**, allow the mic, and t - **Standard token endpoint** so LiveKit client SDKs connect with zero glue. - **Copy-to-extend** patterns: a `User` slice in the backend, a bare `Assistant` in the agent. -## Build and monetize it: ShipVoice +## Charge for it: ShipVoice Pro - ShipVoice: build and monetize a voice agent in an afternoon + ShipVoice Pro -This starter gets you a working voice agent. What it does not include is the -speed and the business layer: an AI engineer that scaffolds a new agent from a -one-line idea, ready-made templates, per-minute Stripe billing, auth, a call -dashboard, and one-command deploy. +This starter gets you a voice agent. It does not get you a business. The part +that does not one-shot is metering, billing, auth, telephony registration, and +compliance, and that is what **[ShipVoice Pro](https://shipvoice.dev)** ships. -That layer is **[ShipVoice](https://shipvoice.dev)**: a LiveKit boilerplate for -voice-AI SaaS with an AI engineer built in. Open it in any coding agent (Claude -Code, Cursor, whatever you use), describe your agent in a line, and its subagents -scaffold it on a proven pattern, so you build and monetize a voice product in an -afternoon instead of a quarter. +It is a separate repository that shares this one's stack and lineage. It is not +a plugin for this repo and does not depend on it. -- **AI engineer built in**: subagents (architect, prompt engineer, task scaffolder, reviewer) scaffold a new agent from one line -- **10 templates + `shipvoice init`**: pick a template, add keys, scaffold, run -- **Per-minute Stripe billing**, auth, and a call dashboard (recordings, transcripts, per-call cost) -- **Telephony (SIP / PSTN)** set up, plus receptionist and outbound-caller templates -- **One-command deploy** (Docker + Fly / Render / LiveKit Cloud) +- **An AI engineer**: describe an agent in one line and its subagents generate it, then a validation gate refuses to ship one that is malformed. No gallery to pick from. +- **Own your margin**: every minute of STT, LLM, TTS, and telephony metered per provider, so each call carries a cost you can read. +- **Per-minute billing**: Stripe Billing Meters, checkout, and webhook, wired. +- **Auth and entitlement**: end-user accounts behind a paid entitlement gate. +- **Compliance that fails closed**: TCPA and HIPAA gates refuse to author or start an agent that would breach them. +- **Telephony (SIP / PSTN)** with a 10DLC registration runbook. +- **One-command deploy** to Fly. - ShipVoice call dashboard: each call metered per minute and billed through Stripe + The ShipVoice Pro console, showing per-call cost metered by provider and billed through Stripe. Sample data. -Founding access is open now, with lifetime updates. Launches September 2, 2026: -[shipvoice.dev](https://shipvoice.dev) +

Console shown with sample data.

+ +Lifetime updates. Launches September 2, 2026: [shipvoice.dev](https://shipvoice.dev) ## Docs Each package has its own README with details: [`agent/`](agent/README.md) · [`backend/`](backend/README.md) · [`frontend/`](frontend/README.md) +## Third-party code + +Some UI components are vendored from public component registries and are +licensed by their upstream authors, not by this repository. See +[`THIRD_PARTY_NOTICES.md`](THIRD_PARTY_NOTICES.md). + ## License -MIT. See [`LICENSE`](LICENSE). +MIT for the code written here. See [`LICENSE`](LICENSE) and the note above. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..58c20af --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,50 @@ +# Third-party notices + +Some files in this repository were vendored from public component registries +using the shadcn CLI. They are copy-in source, they carry their upstream +authors' licences rather than this repository's MIT licence, and they have no +automatic update path. + +The registries they came from are declared in +[`frontend/components.json`](frontend/components.json). + +## shadcn/ui primitives + +**Path:** `frontend/src/components/ui/` +**Files:** `button.tsx`, `button-group.tsx`, `input.tsx`, `select.tsx`, +`separator.tsx`, `toggle.tsx`, `tooltip.tsx` +**Source:** https://ui.shadcn.com +**Upstream:** https://github.com/shadcn-ui/ui +**Licence:** MIT + +## Vercel AI Elements + +**Path:** `frontend/src/components/ai-elements/` +**Files:** `conversation.tsx`, `message.tsx` +**Source:** https://registry.ai-sdk.dev +**Upstream:** https://github.com/vercel/ai-elements +**Licence:** Apache License 2.0, Copyright 2023 Vercel, Inc. + +## LiveKit Agents UI + +**Path:** `frontend/src/components/agents-ui/` and `frontend/src/hooks/agents-ui/` +**Files:** `agent-audio-visualizer-bar.tsx`, `agent-chat-indicator.tsx`, +`agent-chat-transcript.tsx`, `agent-control-bar.tsx`, +`agent-disconnect-button.tsx`, `agent-session-provider.tsx`, +`agent-track-control.tsx`, `agent-track-toggle.tsx`, +`use-agent-audio-visualizer-bar.ts`, `use-agent-control-bar.ts` +**Source:** https://livekit.com/ui/r/{name}.json +**Licence:** not declared. + +The registry items served from `livekit.com/ui/r/` carry no licence field, and +there is no public source repository for them that we could identify. LiveKit's +adjacent projects (`livekit/components-js`, `livekit/agents`) are Apache 2.0, +but we are not asserting that licence for these files on that basis alone. + +If you redistribute this repository and that matters to you, confirm the terms +with LiveKit directly. + +## ShipVoice Pro screenshots + +Images under `assets/` that depict ShipVoice Pro are included for comparison and +are not part of the MIT grant. See `LICENSE`. From e401b79157544312fcddaa58bc0ca99ae6816974 Mon Sep 17 00:00:00 2001 From: Mahimai Raja J Date: Sat, 8 Aug 2026 12:30:30 -0400 Subject: [PATCH 03/28] fix(backend): close registration and make the admin gate a dependency The console is about to read call transcripts, and a login form alone does not protect them: /users/register is public, so a stranger who finds a deployed instance registers, is no longer anonymous, and reads every row. Two changes close it. Registration is now off unless ALLOW_OPEN_REGISTRATION is set, and the gate runs before the service so nothing is created and then refused. And _require_admin, which callers had to remember to invoke inside a handler body, becomes an AdminUser dependency in a new src/api/deps.py, so a future endpoint that forgets it cannot end up silently public. --- .env.example | 5 + backend/.env.example | 5 + backend/src/api/deps.py | 45 ++++++ backend/src/api/endpoints/users.py | 39 ++---- backend/src/core/config.py | 5 + backend/src/core/container.py | 1 + backend/tests/unit/test_registration_gate.py | 138 +++++++++++++++++++ 7 files changed, 213 insertions(+), 25 deletions(-) create mode 100644 backend/src/api/deps.py create mode 100644 backend/tests/unit/test_registration_gate.py diff --git a/.env.example b/.env.example index 4bf8956..1bbeb13 100644 --- a/.env.example +++ b/.env.example @@ -17,6 +17,11 @@ AGENT_NAME=assistant ENV=dev # Generate a strong value for anything you deploy (>= 32 chars). JWT_SECRET_KEY=change-me-in-prod-change-me-in-prod-32chars-min + +# Registration is closed by default. The console reads call transcripts, so an +# open register endpoint on a deployed instance hands them to whoever finds the +# URL. Leave this false and let '/setup' create the first account. +ALLOW_OPEN_REGISTRATION=false # Allow the frontend origin so the browser can call /token. CORS_ORIGINS_STR=http://localhost:5173 # Postgres (the compose 'db' service). Only needed for the auth/User endpoints. diff --git a/backend/.env.example b/backend/.env.example index 8c42b6f..bbd8f7e 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -26,6 +26,11 @@ DB_SSL=disable # MUST be overridden in production. Use a long random string (>= 32 chars). JWT_SECRET_KEY=change-me-in-prod-change-me-in-prod-32chars-min JWT_ALGORITHM=HS256 + +# Registration is closed by default. The console reads call transcripts, so an +# open register endpoint on a deployed instance hands them to whoever finds the +# URL. Leave this false and let '/setup' create the first account. +ALLOW_OPEN_REGISTRATION=false ACCESS_TOKEN_EXPIRE_MINUTES=60 # ---- LiveKit (room token minting) ----------------------------------------- diff --git a/backend/src/api/deps.py b/backend/src/api/deps.py new file mode 100644 index 0000000..177c277 --- /dev/null +++ b/backend/src/api/deps.py @@ -0,0 +1,45 @@ +"""Shared request dependencies. + +These live here rather than in ``endpoints/users.py`` so that a new slice can +depend on the admin gate without importing another endpoint module. +""" + +from typing import Annotated, cast + +from dependency_injector.wiring import Provide, inject +from fastapi import Depends + +from src.core.container import Container +from src.core.exceptions import PermissionDeniedError +from src.core.security import get_current_user +from src.models.users_model import User +from src.services.users_service import UsersService + + +@inject +async def get_current_user_record( + user_id: Annotated[int, Depends(get_current_user)], + service: UsersService = Depends(Provide[Container.users_service]), +) -> User: + """Load the authenticated user's record (raises 404 if the account is gone).""" + return cast(User, await service.get_by_id(user_id)) + + +# The full authenticated user record; gives endpoints access to id + is_superuser. +CurrentUser = Annotated[User, Depends(get_current_user_record)] + + +async def get_current_admin(current_user: CurrentUser) -> User: + """Refuse anyone who is merely signed in. + + Authenticated is not the same as entitled here. Registration can be + reopened, and a self-registered account is a valid bearer of a valid token, + so every surface that exposes call content depends on this rather than on + ``CurrentUser``. + """ + if not current_user.is_superuser: + raise PermissionDeniedError(detail="Administrator privileges required") + return current_user + + +AdminUser = Annotated[User, Depends(get_current_admin)] diff --git a/backend/src/api/endpoints/users.py b/backend/src/api/endpoints/users.py index 12526fb..16ce9a7 100644 --- a/backend/src/api/endpoints/users.py +++ b/backend/src/api/endpoints/users.py @@ -1,11 +1,10 @@ -from typing import Annotated, cast - from dependency_injector.wiring import Provide, inject from fastapi import APIRouter, Depends, Query, status +from src.api.deps import AdminUser, CurrentUser +from src.core.config import Config from src.core.container import Container from src.core.exceptions import PermissionDeniedError -from src.core.security import get_current_user from src.models.users_model import User from src.schemas.base_schema import FindBase from src.schemas.users_schemas import ( @@ -20,30 +19,12 @@ router = APIRouter(prefix="/users") -@inject -async def get_current_user_record( - user_id: Annotated[int, Depends(get_current_user)], - service: UsersService = Depends(Provide[Container.users_service]), -) -> User: - """Load the authenticated user's record (raises 404 if the account is gone).""" - return cast(User, await service.get_by_id(user_id)) - - -# The full authenticated user record; gives endpoints access to id + is_superuser. -CurrentUser = Annotated[User, Depends(get_current_user_record)] - - def _require_self_or_admin(actor: User, target_user_id: int) -> None: if actor.id != target_user_id and not actor.is_superuser: raise PermissionDeniedError(detail="Not permitted to access this user") -def _require_admin(actor: User) -> None: - if not actor.is_superuser: - raise PermissionDeniedError(detail="Administrator privileges required") - - -# ---- Public: obtain or create credentials ---------------------------------- +# ---- Credentials ----------------------------------------------------------- @router.post( @@ -56,7 +37,15 @@ def _require_admin(actor: User) -> None: async def register_user( payload: UserCreate, service: UsersService = Depends(Provide[Container.users_service]), + config: Config = Depends(Provide[Container.config]), ): + # Closed by default. This console reads call transcripts, so an open + # register endpoint on a deployed instance hands them to whoever finds the + # URL. The gate runs before the service so no row is created and refused. + if not config.ALLOW_OPEN_REGISTRATION: + raise PermissionDeniedError( + detail="Registration is closed. Set ALLOW_OPEN_REGISTRATION=true to reopen it." + ) return await service.register(payload) @@ -85,14 +74,14 @@ async def read_me(current_user: CurrentUser): ) @inject async def list_users( - current_user: CurrentUser, + current_user: AdminUser, service: UsersService = Depends(Provide[Container.users_service]), page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), search: str | None = None, ): - # Listing/enumerating all accounts is an admin-only operation. - _require_admin(current_user) + # Listing/enumerating all accounts is an admin-only operation. The gate is + # the dependency, not a call inside the body that a future edit can drop. result = await service.get_list( FindBase(page=page, page_size=page_size, search=search), searchable_fields=["full_name", "email"], diff --git a/backend/src/core/config.py b/backend/src/core/config.py index c270d3f..3b4c188 100644 --- a/backend/src/core/config.py +++ b/backend/src/core/config.py @@ -85,6 +85,11 @@ class Config(BaseSettings): PROJECT_NAME: str = "ShipVoice" + # Closed by default. The console reads call transcripts, so an open + # register endpoint on a deployed instance hands them to anyone who finds + # the URL. '/setup' creates the first superuser directly instead. + ALLOW_OPEN_REGISTRATION: bool = False + # CORS: comma-separated origins. Empty means allow all ("*"). CORS_ORIGINS_STR: str | None = "" diff --git a/backend/src/core/container.py b/backend/src/core/container.py index c104e99..5132281 100644 --- a/backend/src/core/container.py +++ b/backend/src/core/container.py @@ -14,6 +14,7 @@ class Container(containers.DeclarativeContainer): wiring_config = containers.WiringConfiguration( modules=[ + "src.api.deps", "src.api.endpoints.users", "src.api.endpoints.token", ], diff --git a/backend/tests/unit/test_registration_gate.py b/backend/tests/unit/test_registration_gate.py new file mode 100644 index 0000000..60286c5 --- /dev/null +++ b/backend/tests/unit/test_registration_gate.py @@ -0,0 +1,138 @@ +"""The gate that stops a deployed console handing out its call transcripts. + +A login form on its own is decorative here: ``POST /users/register`` is public, +so a stranger who finds a deployed instance registers, is no longer anonymous, +and reads everything. Two things close it, and both are tested here: reads +require ``is_superuser``, and registration is shut unless explicitly reopened. +""" + +import pytest +from fastapi import FastAPI +from httpx import ASGITransport, AsyncClient + +from src.api.deps import get_current_admin, get_current_user_record +from src.api.endpoints.users import router as users_router +from src.core.config import Config +from src.core.container import Container +from src.core.exceptions import PermissionDeniedError +from src.models.users_model import User + + +class _StubUsersService: + """Records whether register was reached, so a 403 proves the gate ran first.""" + + def __init__(self) -> None: + self.register_calls = 0 + + async def register(self, payload): + self.register_calls += 1 + return User( + id=1, + email=payload.email, + hashed_password="x", + full_name=payload.full_name, + is_active=True, + is_superuser=False, + ) + + +def _build_app(*, allow_open_registration: bool): + cfg = Config( + ENV="dev", + _env_file=None, + ALLOW_OPEN_REGISTRATION=allow_open_registration, + ) + service = _StubUsersService() + container = Container() + container.config.override(cfg) + container.users_service.override(service) + container.wire(modules=["src.api.endpoints.users", "src.api.deps"]) + + app = FastAPI() + app.include_router(users_router, prefix="/api/v1") + return app, service, container + + +@pytest.fixture +def closed_app(): + app, service, container = _build_app(allow_open_registration=False) + try: + yield app, service + finally: + container.unwire() + + +@pytest.fixture +def open_app(): + app, service, container = _build_app(allow_open_registration=True) + try: + yield app, service + finally: + container.unwire() + + +async def _post_register(app): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + return await client.post( + "/api/v1/users/register", + json={"email": "stranger@example.com", "password": "correct-horse-battery"}, + ) + + +@pytest.mark.asyncio +async def test_registration_is_closed_by_default(closed_app): + app, service = closed_app + resp = await _post_register(app) + assert resp.status_code == 403 + # The gate must run before the service, or a row is created and then refused. + assert service.register_calls == 0 + + +@pytest.mark.asyncio +async def test_registration_can_be_reopened_deliberately(open_app): + app, service = open_app + resp = await _post_register(app) + assert resp.status_code == 201 + assert service.register_calls == 1 + + +@pytest.mark.asyncio +async def test_a_self_registered_account_is_not_an_admin(): + """The exact escalation the gate exists to stop. + + A freshly self-registered account is authenticated. That is not enough to + read the call log, and this asserts the difference. + """ + plain = User( + id=2, + email="stranger@example.com", + hashed_password="x", + is_active=True, + is_superuser=False, + ) + with pytest.raises(PermissionDeniedError): + await get_current_admin(plain) + + +@pytest.mark.asyncio +async def test_the_founders_account_passes_the_admin_gate(): + founder = User( + id=1, + email="founder@example.com", + hashed_password="x", + is_active=True, + is_superuser=True, + ) + assert await get_current_admin(founder) is founder + + +def test_the_admin_gate_is_a_dependency_not_a_bare_helper(): + """Regression guard. + + ``_require_admin`` was a plain function callers had to remember to invoke. + A calls endpoint that forgets is silently public, so the gate is a + dependency now and must stay one. + """ + assert callable(get_current_admin) + assert callable(get_current_user_record) From a5705f976ea9292d18cc54879f279f3fb19da3f5 Mon Sep 17 00:00:00 2001 From: Mahimai Raja J Date: Sat, 8 Aug 2026 12:46:52 -0400 Subject: [PATCH 04/28] chore(backend): drop the uv init scaffolding src/backend/__init__.py was a 53-byte "Hello from backend!" left over from uv init, reachable only through the [project.scripts] entry that pointed at it. Nothing imports either one: the app resolves src.main through PYTHONPATH, both locally and in the image. Removing the directory alone would break uv, which derives the package to build from the project name and so expects src/backend to exist, so mark the project as an application with tool.uv.package = false. That flips one line in uv.lock from editable to virtual, which has to be committed because the Dockerfile syncs --frozen. Verified: uv sync, 34 tests, ruff, mypy, a docker compose build of the backend image, and /health on the rebuilt container. --- backend/pyproject.toml | 7 +++++-- backend/src/backend/__init__.py | 2 -- backend/uv.lock | 2 +- 3 files changed, 6 insertions(+), 5 deletions(-) delete mode 100644 backend/src/backend/__init__.py diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 448d67d..8ddcc1b 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -24,8 +24,11 @@ dependencies = [ "sqlmodel>=0.0.38", ] -[project.scripts] -backend = "backend:main" +[tool.uv] +# This is an application, not a library: nothing imports it as a package. +# The runtime resolves src.main through PYTHONPATH, so uv must not try to +# build a distribution from a src/ directory that does not exist. +package = false [dependency-groups] dev = [ diff --git a/backend/src/backend/__init__.py b/backend/src/backend/__init__.py deleted file mode 100644 index bc831a6..0000000 --- a/backend/src/backend/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -def main() -> None: - print("Hello from backend!") diff --git a/backend/uv.lock b/backend/uv.lock index 8894f02..0e32286 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -305,7 +305,7 @@ wheels = [ [[package]] name = "backend" version = "0.1.0" -source = { editable = "." } +source = { virtual = "." } dependencies = [ { name = "alembic" }, { name = "asgi-correlation-id" }, From 7f03fbeb677528b5eb4277691308fd5a01a42b2b Mon Sep 17 00:00:00 2001 From: Mahimai Raja J Date: Sat, 8 Aug 2026 12:58:23 -0400 Subject: [PATCH 05/28] feat(backend): describe the configured agent, without pretending to observe it The console's Agents page needs to know what this deployment runs. The backend can read AGENT_NAME, but the provider stack is compiled into the worker at agent/src/agent.py and lives in a different container, so it cannot be observed from here. Declare it instead, name the file it came from in the response, and add a drift test that reads agent/src/agent.py and fails if the strings stop matching. The alternative was a console that keeps confidently reporting a model the agent stopped using. Reads require a superuser, same gate as the rest of the console. --- backend/src/api/endpoints/agents.py | 51 +++++++++ backend/src/api/routes.py | 2 + backend/src/core/config.py | 6 ++ backend/src/core/container.py | 1 + backend/src/schemas/agents_schemas.py | 26 +++++ backend/tests/unit/test_agents_endpoint.py | 118 +++++++++++++++++++++ 6 files changed, 204 insertions(+) create mode 100644 backend/src/api/endpoints/agents.py create mode 100644 backend/src/schemas/agents_schemas.py create mode 100644 backend/tests/unit/test_agents_endpoint.py diff --git a/backend/src/api/endpoints/agents.py b/backend/src/api/endpoints/agents.py new file mode 100644 index 0000000..5378faf --- /dev/null +++ b/backend/src/api/endpoints/agents.py @@ -0,0 +1,51 @@ +from dependency_injector.wiring import Provide, inject +from fastapi import APIRouter, Depends + +from src.api.deps import AdminUser +from src.core.config import Config +from src.core.container import Container +from src.schemas.agents_schemas import AgentListResponse, AgentSummary + +router = APIRouter(prefix="/agents", tags=["agents"]) + +# The worker builds its session with these, at agent/src/agent.py. They are +# constants there, not configuration, so the backend cannot read them from the +# environment and must not pretend to observe them. A drift test asserts these +# still match the file. +DECLARED_IN = "agent/src/agent.py" +DECLARED_STT = "deepgram nova-3" +DECLARED_LLM = "openai gpt-4.1-mini" +# Cartesia is constructed as cartesia.TTS() with no model argument, so the +# plugin default applies. Naming a model here would be a guess. +DECLARED_TTS = "cartesia (plugin default)" + +PROMPT_PATH = "agent/prompts/instructions.txt" + + +@router.get("", response_model=AgentListResponse) +@inject +async def list_agents( + _actor: AdminUser, + config: Config = Depends(Provide[Container.config]), +) -> AgentListResponse: + """List the agents this deployment runs. + + Free runs exactly one worker serving one agent, selected by AGENT_NAME, + so this returns a single row. Running several agents from one account is + ShipVoice Pro. + """ + return AgentListResponse( + agents=[ + AgentSummary( + slug=config.AGENT_NAME, + agent_name=config.AGENT_NAME, + business_name=config.BUSINESS_NAME, + active=True, + prompt_path=PROMPT_PATH, + stt=DECLARED_STT, + llm=DECLARED_LLM, + tts=DECLARED_TTS, + declared_in=DECLARED_IN, + ) + ] + ) diff --git a/backend/src/api/routes.py b/backend/src/api/routes.py index 37fa47d..fbe792e 100644 --- a/backend/src/api/routes.py +++ b/backend/src/api/routes.py @@ -1,8 +1,10 @@ from fastapi import APIRouter +from src.api.endpoints.agents import router as agents_router from src.api.endpoints.token import router as token_router from src.api.endpoints.users import router as users_router routers = APIRouter(prefix="/v1") routers.include_router(users_router) routers.include_router(token_router) +routers.include_router(agents_router) diff --git a/backend/src/core/config.py b/backend/src/core/config.py index 3b4c188..c85a099 100644 --- a/backend/src/core/config.py +++ b/backend/src/core/config.py @@ -85,6 +85,12 @@ class Config(BaseSettings): PROJECT_NAME: str = "ShipVoice" + # The agent's dispatch identity. Must equal the worker's AGENT_NAME and the + # frontend's VITE_AGENT_NAME exactly, or LiveKit never dispatches the + # worker into the room and the call sits in "connecting" with no error. + AGENT_NAME: str = "assistant" + BUSINESS_NAME: str | None = None + # Closed by default. The console reads call transcripts, so an open # register endpoint on a deployed instance hands them to anyone who finds # the URL. '/setup' creates the first superuser directly instead. diff --git a/backend/src/core/container.py b/backend/src/core/container.py index 5132281..4756f6f 100644 --- a/backend/src/core/container.py +++ b/backend/src/core/container.py @@ -15,6 +15,7 @@ class Container(containers.DeclarativeContainer): wiring_config = containers.WiringConfiguration( modules=[ "src.api.deps", + "src.api.endpoints.agents", "src.api.endpoints.users", "src.api.endpoints.token", ], diff --git a/backend/src/schemas/agents_schemas.py b/backend/src/schemas/agents_schemas.py new file mode 100644 index 0000000..a10ab24 --- /dev/null +++ b/backend/src/schemas/agents_schemas.py @@ -0,0 +1,26 @@ +from pydantic import BaseModel + + +class AgentSummary(BaseModel): + """One agent, as the backend can honestly describe it. + + The provider fields are DECLARED, not observed. They are compiled into the + worker at 'agent/src/agent.py' and the backend cannot see that process, so + the console presents them as what the file says rather than as a reading. + 'tests/unit/test_agents_endpoint.py' reads that file and fails if these + drift from it. + """ + + slug: str + agent_name: str + business_name: str | None + active: bool + prompt_path: str + stt: str | None + llm: str | None + tts: str | None + declared_in: str + + +class AgentListResponse(BaseModel): + agents: list[AgentSummary] diff --git a/backend/tests/unit/test_agents_endpoint.py b/backend/tests/unit/test_agents_endpoint.py new file mode 100644 index 0000000..e405cd0 --- /dev/null +++ b/backend/tests/unit/test_agents_endpoint.py @@ -0,0 +1,118 @@ +"""GET /api/v1/agents, and the guard that keeps it honest. + +The provider strings this endpoint reports are compiled into the worker, not +configured, so the backend cannot observe them. It declares them instead, and +the drift test below reads the worker's source and fails if they stop matching. +Without it, the console would keep confidently reporting a model the agent +stopped using. +""" + +import pathlib + +import pytest +from fastapi import FastAPI +from httpx import ASGITransport, AsyncClient + +from src.api.deps import get_current_admin, get_current_user_record +from src.api.endpoints.agents import ( + DECLARED_IN, + DECLARED_LLM, + DECLARED_STT, + DECLARED_TTS, +) +from src.api.endpoints.agents import ( + router as agents_router, +) +from src.core.config import Config +from src.core.container import Container +from src.models.users_model import User + +REPO_ROOT = pathlib.Path(__file__).resolve().parents[3] + + +def _build_app(actor: User): + cfg = Config( + ENV="dev", + _env_file=None, + AGENT_NAME="assistant", + BUSINESS_NAME="Test Business", + ) + container = Container() + container.config.override(cfg) + container.wire(modules=["src.api.endpoints.agents", "src.api.deps"]) + + app = FastAPI() + app.include_router(agents_router, prefix="/api/v1") + # Bypass the JWT decode; this suite is about the endpoint and its gate. + app.dependency_overrides[get_current_user_record] = lambda: actor + if actor.is_superuser: + app.dependency_overrides[get_current_admin] = lambda: actor + return app, container + + +async def _get_agents(app): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + return await client.get("/api/v1/agents") + + +@pytest.mark.asyncio +async def test_lists_the_single_configured_agent(): + admin = User( + id=1, email="f@e.co", hashed_password="x", is_active=True, is_superuser=True + ) + app, container = _build_app(admin) + try: + resp = await _get_agents(app) + assert resp.status_code == 200 + agents = resp.json()["agents"] + # Free runs one worker serving one agent. Several agents is Pro. + assert len(agents) == 1 + assert agents[0]["agent_name"] == "assistant" + assert agents[0]["active"] is True + assert agents[0]["declared_in"] == DECLARED_IN + finally: + container.unwire() + + +@pytest.mark.asyncio +async def test_a_non_admin_cannot_list_agents(): + plain = User( + id=2, email="s@e.co", hashed_password="x", is_active=True, is_superuser=False + ) + app, container = _build_app(plain) + try: + resp = await _get_agents(app) + assert resp.status_code == 403 + finally: + container.unwire() + + +def test_no_cost_field_leaks_into_the_agent_schema(): + """Free reports what an agent is, never what it costs.""" + from src.schemas.agents_schemas import AgentSummary + + banned = {"cost", "cost_usd", "billed", "billed_usd", "kept", "kept_usd", "margin"} + assert banned.isdisjoint(AgentSummary.model_fields.keys()) + + +@pytest.mark.skipif( + not (REPO_ROOT / "agent" / "src" / "agent.py").exists(), + reason="agent source not present (backend deployed on its own)", +) +def test_declared_providers_still_match_the_worker(): + """Drift guard. The console must not report a model the agent stopped using.""" + source = (REPO_ROOT / "agent" / "src" / "agent.py").read_text() + + assert 'deepgram.STT(model="nova-3")' in source, ( + f"agent.py changed its STT; {DECLARED_STT!r} in agents.py is now a lie" + ) + assert 'openai.LLM(model="gpt-4.1-mini")' in source, ( + f"agent.py changed its LLM; {DECLARED_LLM!r} in agents.py is now a lie" + ) + # Cartesia is constructed with no model argument, so the plugin default + # applies and naming one would be a guess. If a model is pinned later, + # this fails and the declared string has to be updated to match. + assert "cartesia.TTS()" in source, ( + f"agent.py pinned a Cartesia model; {DECLARED_TTS!r} is now wrong" + ) From 5ce578f1589a2fab6b1b5d82f269679751febc92 Mon Sep 17 00:00:00 2001 From: Mahimai Raja J Date: Sat, 8 Aug 2026 13:15:23 -0400 Subject: [PATCH 06/28] feat(frontend): the ShipVoice console, with four surfaces marked Pro Replaces the single-screen voice UI with the console ShipVoice Pro ships: same 240px rail, 56px topbar, 48px footer, 4px grid, ShipVoice red, Geist. Watch (Overview, Calls), Run (Campaigns, Channels, Customers), Build (Agents, Evaluations), Settings in the rail footer. The voice call is not gone, it moved. It lives on Agent detail as the test call, still driven by this repo's own vendored LiveKit components rather than Pro's BarVisualizer, because they are better and they were already proven against a live project. Two kinds of lock, and the difference matters. The four Run and Evaluations routes are surfaces Pro has designed and not backed, so they are roadmap previews that say so and never imply that paying switches them on. The things worth paying for are locked in place on the live pages instead, over features Pro has actually shipped: the cost/billed/kept strip on Overview, the receipt on Call detail, cost and kept columns on Calls, the AI engineer on Agents, billing and compliance on Settings. Three collisions had to be solved to land Pro's stylesheet on this repo: - Both stylesheets defined --accent, --chart-1..4 and --radius-sm/md/lg on :root, and free's @theme inline maps them into Tailwind. agent-control-bar paints a muted mic with data-[state=off]:bg-accent, so Pro's #ff3344 would have rendered a muted microphone in brand red, reading as live. Pro's are namespaced --sv-*. - Pro disables Tailwind preflight and carries its own global reset. Free's twelve vendored components were authored against preflight, so preflight stays and Pro's reset is scoped under .sv-console. Unlayered rules beat @layer base, so the console still wins without turning anything off. - Pro imports Geist from fonts.googleapis.com. Geist was already a dependency here and imported nowhere, so it is bundled instead and a deployed console makes no third-party request. Also plumbs VITE_API_BASE_URL through the env examples, the Dockerfile and compose. Nothing but the token endpoint was called before this, so a deployed console would have silently talked to localhost. --- .env.example | 3 + backend/src/api/endpoints/agents.py | 4 +- backend/tests/unit/test_agents_endpoint.py | 16 + docker-compose.yml | 1 + frontend/.env.example | 4 + frontend/Dockerfile | 2 + frontend/package.json | 1 + frontend/pnpm-lock.yaml | 25 + frontend/src/App.css | 184 --- frontend/src/App.tsx | 74 +- frontend/src/api.ts | 175 +++ frontend/src/components/AppShell.tsx | 113 ++ frontend/src/components/LoginForm.tsx | 56 + frontend/src/components/Rail.tsx | 101 ++ frontend/src/components/TestCall.tsx | 184 +++ frontend/src/components/app/session-view.tsx | 48 - frontend/src/components/app/welcome.test.tsx | 10 - frontend/src/components/app/welcome.tsx | 17 - frontend/src/components/ds.tsx | 371 ++++++ frontend/src/console.css | 1076 ++++++++++++++++++ frontend/src/index.css | 6 + frontend/src/lib/format.ts | 23 + frontend/src/main.tsx | 18 +- frontend/src/pages/AgentDetail.tsx | 177 +++ frontend/src/pages/Agents.tsx | 171 +++ frontend/src/pages/CallDetail.tsx | 270 +++++ frontend/src/pages/CallLogs.tsx | 271 +++++ frontend/src/pages/Overview.tsx | 327 ++++++ frontend/src/pages/Settings.tsx | 226 ++++ frontend/src/pages/locked/Campaigns.tsx | 15 + frontend/src/pages/locked/Channels.tsx | 15 + frontend/src/pages/locked/Customers.tsx | 14 + frontend/src/pages/locked/Evaluations.tsx | 15 + frontend/src/pages/locked/LockedPage.tsx | 48 + frontend/src/pages/locked/locked.test.tsx | 56 + frontend/src/types.ts | 66 ++ 36 files changed, 3892 insertions(+), 291 deletions(-) delete mode 100644 frontend/src/App.css create mode 100644 frontend/src/api.ts create mode 100644 frontend/src/components/AppShell.tsx create mode 100644 frontend/src/components/LoginForm.tsx create mode 100644 frontend/src/components/Rail.tsx create mode 100644 frontend/src/components/TestCall.tsx delete mode 100644 frontend/src/components/app/session-view.tsx delete mode 100644 frontend/src/components/app/welcome.test.tsx delete mode 100644 frontend/src/components/app/welcome.tsx create mode 100644 frontend/src/components/ds.tsx create mode 100644 frontend/src/console.css create mode 100644 frontend/src/lib/format.ts create mode 100644 frontend/src/pages/AgentDetail.tsx create mode 100644 frontend/src/pages/Agents.tsx create mode 100644 frontend/src/pages/CallDetail.tsx create mode 100644 frontend/src/pages/CallLogs.tsx create mode 100644 frontend/src/pages/Overview.tsx create mode 100644 frontend/src/pages/Settings.tsx create mode 100644 frontend/src/pages/locked/Campaigns.tsx create mode 100644 frontend/src/pages/locked/Channels.tsx create mode 100644 frontend/src/pages/locked/Customers.tsx create mode 100644 frontend/src/pages/locked/Evaluations.tsx create mode 100644 frontend/src/pages/locked/LockedPage.tsx create mode 100644 frontend/src/pages/locked/locked.test.tsx create mode 100644 frontend/src/types.ts diff --git a/.env.example b/.env.example index 1bbeb13..a655847 100644 --- a/.env.example +++ b/.env.example @@ -36,5 +36,8 @@ DB_SSL=disable VITE_TOKEN_ENDPOINT=http://localhost:8000/api/v1/token VITE_AGENT_NAME=assistant +# Backend base URL the console calls (calls, agents, sign in). +VITE_API_BASE_URL=http://localhost:8000 + # ===== Optional ===== # SENTRY_DSN= diff --git a/backend/src/api/endpoints/agents.py b/backend/src/api/endpoints/agents.py index 5378faf..cb89dfc 100644 --- a/backend/src/api/endpoints/agents.py +++ b/backend/src/api/endpoints/agents.py @@ -19,7 +19,9 @@ # plugin default applies. Naming a model here would be a guess. DECLARED_TTS = "cartesia (plugin default)" -PROMPT_PATH = "agent/prompts/instructions.txt" +# The prompt the worker actually loads today. It is a Python constant, not +# a data file; the console links to it so the buyer knows where to edit. +PROMPT_PATH = "agent/src/prompts/instructions.py" @router.get("", response_model=AgentListResponse) diff --git a/backend/tests/unit/test_agents_endpoint.py b/backend/tests/unit/test_agents_endpoint.py index e405cd0..36fb8ef 100644 --- a/backend/tests/unit/test_agents_endpoint.py +++ b/backend/tests/unit/test_agents_endpoint.py @@ -19,6 +19,7 @@ DECLARED_LLM, DECLARED_STT, DECLARED_TTS, + PROMPT_PATH, ) from src.api.endpoints.agents import ( router as agents_router, @@ -116,3 +117,18 @@ def test_declared_providers_still_match_the_worker(): assert "cartesia.TTS()" in source, ( f"agent.py pinned a Cartesia model; {DECLARED_TTS!r} is now wrong" ) + + +@pytest.mark.skipif( + not (REPO_ROOT / "agent").exists(), + reason="agent source not present (backend deployed on its own)", +) +def test_the_prompt_path_points_at_a_file_that_exists(): + """The console tells the buyer where to edit their agent's prompt. + + This shipped once pointing at a path that did not exist, which sends + someone to create a file the worker never reads. + """ + assert (REPO_ROOT / PROMPT_PATH).exists(), ( + f"agents.py advertises {PROMPT_PATH!r} and nothing is there" + ) diff --git a/docker-compose.yml b/docker-compose.yml index 057bcf6..603f919 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -46,6 +46,7 @@ services: args: VITE_TOKEN_ENDPOINT: ${VITE_TOKEN_ENDPOINT:-http://localhost:8000/api/v1/token} VITE_AGENT_NAME: ${VITE_AGENT_NAME:-assistant} + VITE_API_BASE_URL: ${VITE_API_BASE_URL:-http://localhost:8000} ports: - "5173:80" # http://localhost:5173 depends_on: diff --git a/frontend/.env.example b/frontend/.env.example index 35d0550..eeddc9c 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -3,3 +3,7 @@ VITE_TOKEN_ENDPOINT=http://localhost:8000/api/v1/token # Must match the agent worker's agent_name (AGENT_NAME in the agent package). VITE_AGENT_NAME=assistant + +# Backend base URL for the console (calls, agents, sign in). Baked at build +# time like the two above, so changing it needs a rebuild, not a restart. +VITE_API_BASE_URL=http://localhost:8000 diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 247049f..a8dab34 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -10,8 +10,10 @@ COPY . . # Vite bakes env at build time; the browser calls the backend on the host. ARG VITE_TOKEN_ENDPOINT=http://localhost:8000/api/v1/token ARG VITE_AGENT_NAME=assistant +ARG VITE_API_BASE_URL=http://localhost:8000 ENV VITE_TOKEN_ENDPOINT=$VITE_TOKEN_ENDPOINT ENV VITE_AGENT_NAME=$VITE_AGENT_NAME +ENV VITE_API_BASE_URL=$VITE_API_BASE_URL RUN pnpm build # ---- serve ---- diff --git a/frontend/package.json b/frontend/package.json index e451a00..906d85d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -30,6 +30,7 @@ "radix-ui": "^1.6.0", "react": "^19.2.6", "react-dom": "^19.2.6", + "react-router": "^8.3.0", "shadcn": "^4.11.0", "streamdown": "^2.5.0", "tailwind-merge": "^3.6.0", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index d6d4bc7..378b0ce 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -59,6 +59,9 @@ importers: react-dom: specifier: ^19.2.6 version: 19.2.7(react@19.2.7) + react-router: + specifier: ^8.3.0 + version: 8.3.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) shadcn: specifier: ^4.11.0 version: 4.11.0(typescript@6.0.3) @@ -2049,6 +2052,9 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie-es@3.1.1: + resolution: {integrity: sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==} + cookie-signature@1.2.2: resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} engines: {node: '>=6.6.0'} @@ -3738,6 +3744,16 @@ packages: '@types/react': optional: true + react-router@8.3.0: + resolution: {integrity: sha512-qyPMvW83jGIct3yiieisxdk9M745anqhpIMKN5m1t6yBMfgVPpt77aHOqs5fUlEJRMCGffg9BaQLH9oPVOL7xQ==} + engines: {node: '>=22.22.0'} + peerDependencies: + react: '>=19.2.7' + react-dom: '>=19.2.7' + peerDependenciesMeta: + react-dom: + optional: true + react-style-singleton@2.2.3: resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} engines: {node: '>=10'} @@ -6462,6 +6478,8 @@ snapshots: convert-source-map@2.0.0: {} + cookie-es@3.1.1: {} + cookie-signature@1.2.2: {} cookie@0.7.2: {} @@ -8453,6 +8471,13 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 + react-router@8.3.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + cookie-es: 3.1.1 + react: 19.2.7 + optionalDependencies: + react-dom: 19.2.7(react@19.2.7) + react-style-singleton@2.2.3(@types/react@19.2.17)(react@19.2.7): dependencies: get-nonce: 1.0.1 diff --git a/frontend/src/App.css b/frontend/src/App.css deleted file mode 100644 index f90339d..0000000 --- a/frontend/src/App.css +++ /dev/null @@ -1,184 +0,0 @@ -.counter { - font-size: 16px; - padding: 5px 10px; - border-radius: 5px; - color: var(--accent); - background: var(--accent-bg); - border: 2px solid transparent; - transition: border-color 0.3s; - margin-bottom: 24px; - - &:hover { - border-color: var(--accent-border); - } - &:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; - } -} - -.hero { - position: relative; - - .base, - .framework, - .vite { - inset-inline: 0; - margin: 0 auto; - } - - .base { - width: 170px; - position: relative; - z-index: 0; - } - - .framework, - .vite { - position: absolute; - } - - .framework { - z-index: 1; - top: 34px; - height: 28px; - transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg) - scale(1.4); - } - - .vite { - z-index: 0; - top: 107px; - height: 26px; - width: auto; - transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg) - scale(0.8); - } -} - -#center { - display: flex; - flex-direction: column; - gap: 25px; - place-content: center; - place-items: center; - flex-grow: 1; - - @media (max-width: 1024px) { - padding: 32px 20px 24px; - gap: 18px; - } -} - -#next-steps { - display: flex; - border-top: 1px solid var(--border); - text-align: left; - - & > div { - flex: 1 1 0; - padding: 32px; - @media (max-width: 1024px) { - padding: 24px 20px; - } - } - - .icon { - margin-bottom: 16px; - width: 22px; - height: 22px; - } - - @media (max-width: 1024px) { - flex-direction: column; - text-align: center; - } -} - -#docs { - border-right: 1px solid var(--border); - - @media (max-width: 1024px) { - border-right: none; - border-bottom: 1px solid var(--border); - } -} - -#next-steps ul { - list-style: none; - padding: 0; - display: flex; - gap: 8px; - margin: 32px 0 0; - - .logo { - height: 18px; - } - - a { - color: var(--text-h); - font-size: 16px; - border-radius: 6px; - background: var(--social-bg); - display: flex; - padding: 6px 12px; - align-items: center; - gap: 8px; - text-decoration: none; - transition: box-shadow 0.3s; - - &:hover { - box-shadow: var(--shadow); - } - .button-icon { - height: 18px; - width: 18px; - } - } - - @media (max-width: 1024px) { - margin-top: 20px; - flex-wrap: wrap; - justify-content: center; - - li { - flex: 1 1 calc(50% - 8px); - } - - a { - width: 100%; - justify-content: center; - box-sizing: border-box; - } - } -} - -#spacer { - height: 88px; - border-top: 1px solid var(--border); - @media (max-width: 1024px) { - height: 48px; - } -} - -.ticks { - position: relative; - width: 100%; - - &::before, - &::after { - content: ''; - position: absolute; - top: -4.5px; - border: 5px solid transparent; - } - - &::before { - left: 0; - border-left-color: var(--border); - } - &::after { - right: 0; - border-right-color: var(--border); - } -} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2906843..e7a5f2f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,30 +1,56 @@ -import { useSession, useSessionContext } from "@livekit/components-react"; -import { TooltipProvider } from "@/components/ui/tooltip"; -import { AgentSessionProvider } from "@/components/agents-ui/agent-session-provider"; -import { AGENT_NAME, tokenSource } from "@/lib/token-source"; -import { Welcome } from "@/components/app/welcome"; -import { SessionView } from "@/components/app/session-view"; +import { useCallback, useEffect, useState } from "react"; +import { Navigate, Route, Routes } from "react-router"; +import { SESSION_EXPIRED_EVENT, clearToken, getToken } from "./api"; +import { AppShell } from "./components/AppShell"; +import { LoginForm } from "./components/LoginForm"; +import { Overview } from "./pages/Overview"; +import { CallLogs } from "./pages/CallLogs"; +import { CallDetail } from "./pages/CallDetail"; +import { Agents } from "./pages/Agents"; +import { AgentDetail } from "./pages/AgentDetail"; +import { Settings } from "./pages/Settings"; +import { Campaigns } from "./pages/locked/Campaigns"; +import { Channels } from "./pages/locked/Channels"; +import { Customers } from "./pages/locked/Customers"; +import { Evaluations } from "./pages/locked/Evaluations"; -function AppShell() { - // The view switches on room-connection state. Clicking "End call" disconnects - // the room, which flips isConnected to false and returns to the welcome screen - // cleanly (no error surfaced for a normal hang-up). - const session = useSessionContext(); - if (!session.isConnected) { - return void session.start()} />; +export default function App() { + const [loggedIn, setLoggedIn] = useState(() => getToken() != null); + + // A 401 anywhere means the token lapsed. api.ts clears it and fires this + // event so every page does not have to handle expiry on its own. + useEffect(() => { + const onExpired = (): void => setLoggedIn(false); + window.addEventListener(SESSION_EXPIRED_EVENT, onExpired); + return () => window.removeEventListener(SESSION_EXPIRED_EVENT, onExpired); + }, []); + + const handleLogout = useCallback(() => { + clearToken(); + setLoggedIn(false); + }, []); + + if (!loggedIn) { + return setLoggedIn(true)} />; } - return void session.end()} />; -} -export default function App() { - const session = useSession(tokenSource, { agentName: AGENT_NAME }); return ( - - -
- -
-
-
+ + }> + } /> + } /> + } /> + } /> + } /> + } /> + {/* Designed in ShipVoice Pro, not backed there yet. These routes open + and say so; they are previews, not a paywall. */} + } /> + } /> + } /> + } /> + } /> + + ); } diff --git a/frontend/src/api.ts b/frontend/src/api.ts new file mode 100644 index 0000000..b09c76a --- /dev/null +++ b/frontend/src/api.ts @@ -0,0 +1,175 @@ +// The only module that knows the base URL, token storage, and auth headers. +import type { + AgentListResponse, + CallDetailResponse, + CallListResponse, + CallSummaryResponse, + RoomTokenResponse, +} from "./types"; + +export const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000"; + +const TOKEN_KEY = "shipvoice_token"; + +export function getToken(): string | null { + return localStorage.getItem(TOKEN_KEY); +} + +export function setToken(token: string): void { + localStorage.setItem(TOKEN_KEY, token); +} + +export function clearToken(): void { + localStorage.removeItem(TOKEN_KEY); +} + +export function authHeaders(): Record { + const t = getToken(); + return t ? { Authorization: `Bearer ${t}` } : {}; +} + +/** Broadcast when a request comes back 401, so App can drop to the login form. */ +export const SESSION_EXPIRED_EVENT = "shipvoice:session-expired"; + +/** + * A failed request, with the three states the UI must never conflate. + * + * Rendering "API unreachable" for a lapsed token sends people to check Docker + * when the fix is to sign in again, and rendering it for a 403 hides the fact + * that the account simply is not an admin. + */ +export class ApiError extends Error { + readonly status: number; + + constructor(status: number, message: string) { + super(message); + this.name = "ApiError"; + this.status = status; + } + + get isExpiredSession(): boolean { + return this.status === 401; + } + + get isForbidden(): boolean { + return this.status === 403; + } + + get isUnreachable(): boolean { + return this.status === 0 || this.status >= 500; + } + + /** The calls slice is not wired yet on a checkout that has not run it. */ + get isMissing(): boolean { + return this.status === 404; + } +} + +async function guard(res: Response, what: string): Promise { + if (res.ok) return; + if (res.status === 401) { + clearToken(); + window.dispatchEvent(new Event(SESSION_EXPIRED_EVENT)); + } + throw new ApiError(res.status, `${what} failed (${res.status})`); +} + +async function get(path: string, what: string): Promise { + let res: Response; + try { + res = await fetch(`${API_BASE}${path}`, { headers: authHeaders() }); + } catch { + throw new ApiError(0, `${what} could not reach the backend`); + } + await guard(res, what); + return (await res.json()) as T; +} + +export async function login(email: string, password: string): Promise { + let res: Response; + try { + // Free's route is /users/login. Pro's is /auth/login; a verbatim port 404s. + res = await fetch(`${API_BASE}/api/v1/users/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password }), + }); + } catch { + throw new ApiError(0, "Could not reach the backend"); + } + await guard(res, "Sign in"); + const data = (await res.json()) as { access_token: string }; + setToken(data.access_token); +} + +export interface CallListParams { + limit?: number; + offset?: number; + channel?: string; + status?: string; +} + +export async function listCalls(params: CallListParams = {}): Promise { + const q = new URLSearchParams(); + q.set("limit", String(params.limit ?? 50)); + q.set("offset", String(params.offset ?? 0)); + if (params.channel && params.channel !== "all") q.set("channel", params.channel); + if (params.status && params.status !== "all") q.set("status", params.status); + // The trailing slash is deliberate: the backend declares @router.get("/") + // under prefix="/calls", and omitting it costs a 307 on every call. + return get(`/api/v1/calls/?${q.toString()}`, "Loading calls"); +} + +export async function getSummary(): Promise { + return get("/api/v1/calls/summary", "Loading the summary"); +} + +export async function getCall(id: number | string): Promise { + return get(`/api/v1/calls/${id}`, "Loading the call"); +} + +export async function deleteCall(id: number | string): Promise { + let res: Response; + try { + res = await fetch(`${API_BASE}/api/v1/calls/${id}`, { + method: "DELETE", + headers: authHeaders(), + }); + } catch { + throw new ApiError(0, "Could not reach the backend"); + } + await guard(res, "Deleting the call"); +} + +export async function listAgents(): Promise { + return get("/api/v1/agents", "Loading agents"); +} + +/** + * Mint a join token for a browser test call. + * + * room_config carries the agent name, which is what dispatches the worker into + * the room. The response shape stays exactly {server_url, participant_token}: + * it follows LiveKit's standardized token schema so any client SDK connects + * without glue, and adding a field to prove dispatch would fork that. The + * console proves dispatch by watching the agent participant actually join. + */ +export async function getTestCallToken(agentName: string): Promise { + const room = `console-${Math.random().toString(16).slice(2, 14)}`; + let res: Response; + try { + res = await fetch(`${API_BASE}/api/v1/token`, { + method: "POST", + headers: { "Content-Type": "application/json", ...authHeaders() }, + body: JSON.stringify({ + room_name: room, + participant_identity: `console-${Math.random().toString(16).slice(2, 10)}`, + room_config: { agents: [{ agent_name: agentName }] }, + }), + }); + } catch { + throw new ApiError(0, "Could not reach the backend"); + } + await guard(res, "Starting the test call"); + return (await res.json()) as RoomTokenResponse; +} diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx new file mode 100644 index 0000000..124d96d --- /dev/null +++ b/frontend/src/components/AppShell.tsx @@ -0,0 +1,113 @@ +import { useEffect, useState, type ReactNode } from "react"; +import { Link, Outlet } from "react-router"; +import { listAgents, listCalls } from "../api"; +import { Rail } from "./Rail"; +import { LiveEventsBar } from "./ds"; + +// The console frame. The rail carries per-section counts, each page owns its own +// topbar, and the footer is a live pulse rather than a static byline. +// +// Everything is wrapped in .sv-console so the ported stylesheet's element rules +// cannot reach the vendored LiveKit and shadcn components used by the test call. +export function AppShell() { + const [callCount, setCallCount] = useState(null); + const [agentCount, setAgentCount] = useState(null); + const [ticks, setTicks] = useState([]); + + useEffect(() => { + let live = true; + listCalls({ limit: 24 }) + .then((r) => { + if (!live) return; + setCallCount(r.total); + setTicks(r.calls.map((c) => c.turn_count).reverse()); + }) + .catch(() => undefined); + // The rail renders an agents count. Fetch it, rather than leaving the + // prop permanently undefined and the chip permanently invisible. + listAgents() + .then((r) => live && setAgentCount(r.agents.length)) + .catch(() => undefined); + return () => { + live = false; + }; + }, []); + + return ( +
+ +
+
+ +
+
+
+ {ticks.length > 0 ? ( + + ) : ( + // No ticks yet: either the backend has not answered or there are + // no recent calls to count turns from. Eight zero bars would draw + // a flat line that reads as measured silence, so say "no data". +
+ + Live events + + + no data + +
+ )} +
+ + View all → + + + {callCount == null ? "no calls recorded yet" : `${callCount.toLocaleString()} calls recorded`} + +
+
+
+ ); +} + +/** + * Page header. Optional back link, title, status badge, a meta line, then + * right-aligned actions. + */ +export function TopBar({ + back, + title, + badge, + meta, + actions, +}: { + back?: { to: string; label: string }; + title: ReactNode; + badge?: ReactNode; + meta?: ReactNode; + actions?: ReactNode; +}) { + return ( +
+ {back && ( + + ← {back.label} + + )} +

{title}

+ {badge} + {meta && ( + + {meta} + + )} +
+ {actions} +
+ ); +} + +/** The accent-coloured annotation the console uses to explain itself. */ +export function Ann({ children }: { children: ReactNode }) { + return
↳ {children}
; +} diff --git a/frontend/src/components/LoginForm.tsx b/frontend/src/components/LoginForm.tsx new file mode 100644 index 0000000..d810b2e --- /dev/null +++ b/frontend/src/components/LoginForm.tsx @@ -0,0 +1,56 @@ +import { useState, type FormEvent } from "react"; +import { ApiError, login } from "../api"; + +export function LoginForm({ onLoggedIn }: { onLoggedIn: () => void }) { + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + + async function handleSubmit(event: FormEvent): Promise { + event.preventDefault(); + setError(null); + try { + await login(email, password); + onLoggedIn(); + } catch (e) { + // Distinguish a dead backend from a bad password. Telling someone their + // password is wrong when Docker is not running sends them to the wrong fix. + if (e instanceof ApiError && e.isUnreachable) { + setError("Could not reach the backend. Is it running on " + location.hostname + ":8000?"); + } else { + setError("Sign in failed. Check your email and password."); + } + } + } + + return ( +
+
+

ShipVoice

+

+ Sign in with the account /setup created. +

+ setEmail(event.target.value)} + placeholder="email" + /> + setPassword(event.target.value)} + placeholder="password" + /> + + {error && ( +

+ {error} +

+ )} +
+
+ ); +} diff --git a/frontend/src/components/Rail.tsx b/frontend/src/components/Rail.tsx new file mode 100644 index 0000000..e6714ba --- /dev/null +++ b/frontend/src/components/Rail.tsx @@ -0,0 +1,101 @@ +import { NavLink } from "react-router"; + +// Three groups, matching the ShipVoice Pro console: Watch what happened, Run +// what is dialling, Build what dials next. Settings is not a nav item; it lives +// in the footer under the deployment identity. +// +// Four items are locked. They are ShipVoice Pro surfaces that Pro has designed +// and not yet backed, so they are previews, not a paywall: the route still +// opens and says plainly what is and is not there. The features actually worth +// paying for are locked in place on the live pages instead, via LockedBlock. +type Item = { label: string; to: string; count?: number | null; locked?: boolean }; +type Group = { title: string; items: Item[] }; + +export function Rail({ + counts = {}, + deployment = "this deployment", +}: { + counts?: Record; + deployment?: string; +}) { + const groups: Group[] = [ + { + title: "Watch", + items: [ + { label: "Overview", to: "/" }, + { label: "Calls", to: "/calls", count: counts.calls ?? null }, + ], + }, + { + title: "Run", + items: [ + { label: "Campaigns", to: "/campaigns", locked: true }, + { label: "Channels", to: "/channels", locked: true }, + { label: "Customers", to: "/customers", locked: true }, + ], + }, + { + title: "Build", + items: [ + { label: "Agents", to: "/agents", count: counts.agents ?? null }, + { label: "Evaluations", to: "/evaluations", locked: true }, + ], + }, + ]; + + return ( + + ); +} diff --git a/frontend/src/components/TestCall.tsx b/frontend/src/components/TestCall.tsx new file mode 100644 index 0000000..a29be8a --- /dev/null +++ b/frontend/src/components/TestCall.tsx @@ -0,0 +1,184 @@ +// Talk to this deployment's agent from the console. +// +// This is deliberately NOT a port of the ShipVoice Pro test call. Pro drives +// LiveKitRoom + BarVisualizer; this repo already vendors LiveKit's Agents UI +// and the pre-console App.tsx connected through it, verified against a live +// LiveKit project. So the connection path here is exactly that one: +// 'tokenSource' plus useSession(tokenSource, { agentName }), wrapped in +// AgentSessionProvider. Nothing new is invented. +// +// The one addition Pro does not have is the no-join timeout below. +import { useEffect, useState } from "react"; +import { + useAgent, + useSession, + useSessionContext, + useSessionMessages, +} from "@livekit/components-react"; +import { ConnectionState } from "livekit-client"; +import { AgentAudioVisualizerBar } from "@/components/agents-ui/agent-audio-visualizer-bar"; +import { AgentChatTranscript } from "@/components/agents-ui/agent-chat-transcript"; +import { AgentControlBar } from "@/components/agents-ui/agent-control-bar"; +import { AgentSessionProvider } from "@/components/agents-ui/agent-session-provider"; +import { TooltipProvider } from "@/components/ui/tooltip"; +import { AGENT_NAME, tokenSource } from "@/lib/token-source"; +import { Badge, Button } from "./ds"; + +/** + * How long the room may stay empty before the console says so. + * + * LiveKit dispatch matches the agent name as an exact string. When it does not + * match, nothing errors: the token is valid, the room opens, the browser sits + * in "connecting" forever and the logs are clean on both sides. Ten seconds is + * long enough for a cold worker to accept the job and short enough that the + * silence gets explained instead of debugged. + */ +const NO_JOIN_MS = 10_000; + +const MONO = { fontFamily: "var(--font-mono)", color: "var(--text-primary)" }; + +/** + * True once the room has been open for NO_JOIN_MS with no agent in it. + * + * The flag is only ever set, never cleared: an agent that joins late clears the + * warning through the returned expression instead, and the next call clears it + * by remounting, since this component only exists while a call is connected. + */ +function useNoJoinTimeout(agentJoined: boolean): boolean { + const [expired, setExpired] = useState(false); + + useEffect(() => { + if (agentJoined) return; + const timer = window.setTimeout(() => setExpired(true), NO_JOIN_MS); + return () => window.clearTimeout(timer); + }, [agentJoined]); + + return expired && !agentJoined; +} + +/** The name mismatch, named. Both values are printed, not described. */ +function NoAgentWarning() { + return ( +
+ no agent + + Nothing joined this room in 10 seconds. LiveKit matches the agent name as an exact string, + and a mismatch fails silently: valid token, open room, no error on either side. +
+ This browser dispatched {AGENT_NAME}, from VITE_AGENT_NAME in + frontend/.env. +
+ Compare that with AGENT_NAME in agent/.env, the name the worker registers under. The two + must match exactly, character for character. +
+
+ ); +} + +function LiveCall({ onEnd }: { onEnd: () => void }) { + const { state, microphoneTrack, isConnected: agentJoined } = useAgent(); + const { messages } = useSessionMessages(); + const { isConnected } = useSessionContext(); + const timedOut = useNoJoinTimeout(agentJoined); + + return ( +
+
+ + + {state} + +
+ + {timedOut && } + + + + {/* Bundles the mic toggle, the text chat input, and the leave button. */} + +
+ ); +} + +function StartCall({ + connecting, + error, + onStart, +}: { + connecting: boolean; + error: string | null; + onStart: () => void; +}) { + return ( +
+

+ 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. +

+ + {error && ( +
+ error + {error} +
+ )} +
+ ); +} + +export function TestCall() { + // The dispatch name comes from the frontend env, never from the agent row: + // the row is what the backend declares, and dispatch is what this browser + // actually asks LiveKit for. Keeping them separate is what makes a mismatch + // visible instead of hidden behind a shared variable. + const session = useSession(tokenSource, { agentName: AGENT_NAME }); + const [error, setError] = useState(null); + + const start = (): void => { + setError(null); + void session.start().catch((e: unknown) => { + setError( + e instanceof Error + ? `Could not start the call: ${e.message}` + : "Could not start the call.", + ); + }); + }; + + return ( + + + {session.isConnected ? ( + void session.end()} /> + ) : ( + + )} + + + ); +} diff --git a/frontend/src/components/app/session-view.tsx b/frontend/src/components/app/session-view.tsx deleted file mode 100644 index 4d45506..0000000 --- a/frontend/src/components/app/session-view.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { - useAgent, - useSessionContext, - useSessionMessages, -} from "@livekit/components-react"; -import { AgentAudioVisualizerBar } from "@/components/agents-ui/agent-audio-visualizer-bar"; -import { AgentChatTranscript } from "@/components/agents-ui/agent-chat-transcript"; -import { AgentControlBar } from "@/components/agents-ui/agent-control-bar"; - -export function SessionView({ onDisconnect }: { onDisconnect: () => void }) { - const { state, microphoneTrack } = useAgent(); - const { messages } = useSessionMessages(); - const { isConnected } = useSessionContext(); - - return ( -
-
- -

{state}

-
- - - - {/* AgentControlBar bundles the mic toggle, an expandable text chat input - (controls.chat), and the leave button. */} - -
- ); -} diff --git a/frontend/src/components/app/welcome.test.tsx b/frontend/src/components/app/welcome.test.tsx deleted file mode 100644 index 97e05dc..0000000 --- a/frontend/src/components/app/welcome.test.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import { expect, test, vi } from "vitest"; -import { Welcome } from "@/components/app/welcome"; - -test("welcome screen shows a start button", () => { - render(); - expect( - screen.getByRole("button", { name: /start conversation/i }), - ).toBeInTheDocument(); -}); diff --git a/frontend/src/components/app/welcome.tsx b/frontend/src/components/app/welcome.tsx deleted file mode 100644 index 5827f5c..0000000 --- a/frontend/src/components/app/welcome.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { Button } from "@/components/ui/button"; - -export function Welcome({ onStart }: { onStart: () => void }) { - return ( -
-
-

Voice assistant

-

- Click start, allow your microphone, and talk to the agent. -

-
- -
- ); -} diff --git a/frontend/src/components/ds.tsx b/frontend/src/components/ds.tsx new file mode 100644 index 0000000..19a9488 --- /dev/null +++ b/frontend/src/components/ds.tsx @@ -0,0 +1,371 @@ +// Design-system primitives for the console, ported from ShipVoice Pro. +// Presentational only: no data fetching, no routing. +// +// Two differences from the Pro original, both forced by this repo: +// 1. No ': JSX.Element' return annotations. The global JSX namespace was +// removed in @types/react 19 and this app is on 19.2.x, so they do not +// compile here. +// 2. Chart and radius tokens read '--sv-*'. Free's index.css already defines +// '--chart-1..4', '--radius-sm/md/lg' and '--accent' for the vendored +// shadcn components, so console.css namespaces its own. +import type { CSSProperties, ReactNode } from "react"; + +export type Tone = "neutral" | "success" | "warning" | "danger" | "violation" | "accent"; + +const TONE_CLASS: Record = { + neutral: "", + success: "ok", + warning: "wn", + danger: "bad", + violation: "vio", + accent: "acc", +}; + +export function Badge({ tone = "neutral", children }: { tone?: Tone; children: ReactNode }) { + const cls = TONE_CLASS[tone]; + return {children}; +} + +export function Button({ + variant = "secondary", + size = "md", + onClick, + disabled, + title, + children, +}: { + variant?: "primary" | "secondary" | "ghost" | "danger"; + size?: "sm" | "md"; + onClick?: () => void; + disabled?: boolean; + title?: string; + children: ReactNode; +}) { + const v = + variant === "primary" ? "p" : variant === "ghost" ? "g" : variant === "danger" ? "d" : ""; + const classes = ["btn", v, size === "sm" ? "sm" : ""].filter(Boolean).join(" "); + return ( + + ); +} + +export function Panel({ + title, + meta, + actions, + flush, + children, +}: { + title?: ReactNode; + meta?: ReactNode; + actions?: ReactNode; + flush?: boolean; + children: ReactNode; +}) { + return ( +
+ {(title || meta || actions) && ( +
+ {title} + {meta && {meta}} + {actions && {actions}} +
+ )} + {flush ? children :
{children}
} +
+ ); +} + +export function Stat({ + value, + label, + hint, + tone, +}: { + value: ReactNode; + label: string; + hint?: string; + tone?: "success"; +}) { + return ( +
+ {value} + {label} + {hint && {hint}} +
+ ); +} + +/** Segmented progress meter. Segments render left to right in order. */ +export function Meter({ segments }: { segments: { pct: number; color: string; title?: string }[] }) { + return ( +
+ {segments.map((s, i) => ( + + ))} +
+ ); +} + +/** + * Dotted line chart. Pure SVG, no dependency. + * Renders a polyline plus a dot per point, with horizontal grid ticks. + */ +export function DottedLineChart({ + points, + height = 132, + yTicks = 3, + label, + showAxis, + xLabels, +}: { + points: number[]; + height?: number; + yTicks?: number; + label?: string; + showAxis?: boolean; + xLabels?: string[]; +}) { + const w = 100; + const pad = 6; + if (points.length === 0) { + return
No data yet.
; + } + const max = Math.max(...points, 0.0001); + const min = 0; + const span = max - min || 1; + const x = (i: number): number => + points.length === 1 ? w / 2 : (i / (points.length - 1)) * (w - pad * 2) + pad; + const y = (v: number): number => height - pad - ((v - min) / span) * (height - pad * 2); + const path = points.map((p, i) => `${x(i)},${y(p)}`).join(" "); + const ticks = Array.from({ length: yTicks }, (_, i) => (i / (yTicks - 1)) * (height - pad * 2) + pad); + // Axis values run top to bottom, so the first tick is the maximum. + const tickValues = Array.from({ length: yTicks }, (_, i) => max - (i / (yTicks - 1)) * span); + + const svg = ( + + {ticks.map((t, i) => ( + + ))} + + {points.map((p, i) => ( + + ))} + + ); + + if (!showAxis) return svg; + + const axisStyle: CSSProperties = { + width: 34, + flex: "none", + height, + display: "flex", + flexDirection: "column", + justifyContent: "space-between", + font: "var(--type-caption)", + textAlign: "right", + }; + + return ( +
+
+
+ {tickValues.map((v, i) => ( + {v >= 10 ? Math.round(v) : v.toFixed(2)} + ))} +
+
{svg}
+
+ {xLabels && xLabels.length > 0 && ( +
+ {xLabels.map((l, i) => ( + {l} + ))} +
+ )} +
+ ); +} + +/** Live events bar: one tick per recent event, most recent on the right. */ +export function LiveEventsBar({ + ticks, + label = "Live events", + compact, +}: { + ticks: number[]; + label?: string; + compact?: boolean; +}) { + const max = Math.max(...ticks, 1); + const bars = ( +
+ {ticks.map((t, i) => ( + + ))} +
+ ); + + if (compact) { + return ( +
+ + {label} + + {bars} +
+ ); + } + + return ( +
+
+ {label} + + {ticks.reduce((a, b) => a + b, 0)} in window + +
+ {bars} +
+ ); +} + +export function LegendGrid({ + items, + columns = 1, +}: { + items: { label: string; value: string; color?: string }[]; + columns?: number; +}) { + return ( +
+ {items.map((it) => ( +
+ + + + {it.label} + + + + {it.value} + +
+ ))} +
+ ); +} + +/** Key/value row used across detail pages. */ +export function KV({ k, children, mono }: { k: string; children: ReactNode; mono?: boolean }) { + return ( +
+ {k} + {children} +
+ ); +} + +/** + * A surface that is designed but not yet backed by the runtime. + * Never render a plausible-looking empty state for one of these: say so. + */ +export function PlannedNotice({ what }: { what: string }) { + return ( +
+ planned + {what} +
+ ); +} + +export const PRO_URL = "https://shipvoice.dev"; + +/** + * An in-page lock: a real ShipVoice Pro feature, shown in place and greyed. + * + * This is the console's actual sales surface, not the four locked routes. + * Every use must name something Pro has SHIPPED, because a lock over a feature + * that does not exist is worse than no lock at all. + */ +export function LockedBlock({ + what, + children, + inline, +}: { + what: string; + children?: ReactNode; + inline?: boolean; +}) { + return ( +
+ {children && ( + + )} +
+ Pro + {what} + + shipvoice.dev + +
+
+ ); +} diff --git a/frontend/src/console.css b/frontend/src/console.css new file mode 100644 index 0000000..df18208 --- /dev/null +++ b/frontend/src/console.css @@ -0,0 +1,1076 @@ +/* ShipVoice Console. + Ported from the 'lean-dash' design system authored in Claude Design. + Two deliberate changes from the source DS: + 1. The accent ramp is ShipVoice red (#ff3344), not the sampled violet. + The source console already remapped violet to red; this aligns it to + the exact brand red used by the logo, the marketing site, and the free repo. + 2. The face is Geist / Geist Mono, not Space Grotesk, for the same reason. + Everything else (spacing grid, borders-not-shadows, tight radii, tabular + figures, 240px rail, 56px topbar, 48px footer) is the source design. */ + +:root { + /* Neutral ramp (Tailwind neutral, from the source DS) */ + --n-950: #0a0a0a; + --n-900: #171717; + --n-880: #1a1a1a; + --n-850: #202020; + --n-820: #232125; + --n-800: #262626; + --n-750: #303030; + --n-600: #525252; + --n-500: #737373; + --n-400: #a3a3a3; + --n-200: #e5e5e5; + --n-50: #fafafa; + + /* ShipVoice red ramp, replacing the source violet ramp */ + --brand-100: #fff2f3; + --brand-200: #ffc9cd; + --brand-300: #ff6f79; + --brand-400: #ff3344; + --brand-500: #d11f2e; + --brand-700: #8c1620; + --brand-900: #4c1016; + + /* Muted status ramp (from the source DS) */ + --sage-400: #9ab175; + --sand-300: #e7c994; + --clay-400: #b46a5f; + --red-800: #991b1b; + + /* Surfaces */ + --surface-sunken: var(--n-950); + --surface-base: var(--n-900); + --surface-raised: var(--n-850); + --surface-hover: var(--n-880); + --surface-row-hover: var(--n-820); + --surface-selected: var(--n-800); + + /* Borders */ + --border-subtle: #1f1f1f; + --border-default: var(--n-800); + --border-strong: var(--n-750); + + /* Text */ + --text-primary: var(--n-50); + --text-secondary: var(--n-400); + --text-muted: var(--n-500); + --text-faint: var(--n-600); + --text-on-accent: #ffffff; + --text-link: var(--brand-300); + + /* Accent */ + --sv-accent: var(--brand-400); + --accent-hover: var(--brand-300); + --accent-press: var(--brand-500); + --accent-solid: var(--brand-700); + --accent-quiet: var(--brand-900); + --accent-quiet-text: var(--brand-200); + --accent-ring: rgba(255, 51, 68, 0.35); + + /* Status */ + --success: var(--sage-400); + --warning: var(--sand-300); + --danger: var(--clay-400); + --danger-solid: var(--red-800); + --danger-solid-text: #fef2f2; + + /* Data viz */ + --sv-chart-1: var(--brand-400); + --sv-chart-2: var(--brand-300); + --sv-chart-3: var(--brand-500); + --sv-chart-4: var(--brand-200); + --chart-track: var(--n-750); + --chart-grid: var(--border-default); + --chart-dot: var(--brand-300); + + /* Type */ + --font-sans: "Geist", ui-sans-serif, system-ui, "Helvetica Neue", Arial, sans-serif; + --font-mono: "Geist Mono", ui-monospace, SFMono-Regular, Menlo, monospace; + --fs-11: 11px; + --fs-12: 12px; + --fs-13: 13px; + --fs-14: 14px; + --fs-15: 15px; + --fs-16: 16px; + --fs-20: 20px; + --fs-24: 24px; + --fs-30: 30px; + --fs-40: 40px; + --fw-light: 300; + --fw-regular: 400; + --fw-medium: 500; + --fw-semibold: 600; + --fw-bold: 700; + --lh-tight: 1.1; + --lh-snug: 1.25; + --lh-normal: 1.45; + --lh-relaxed: 1.6; + --tracking-label: 0.08em; + + --type-metric-xl: var(--fw-medium) var(--fs-30) / var(--lh-tight) var(--font-sans); + --type-metric: var(--fw-bold) var(--fs-20) / var(--lh-tight) var(--font-sans); + --type-panel-title: var(--fw-medium) var(--fs-16) / var(--lh-snug) var(--font-sans); + --type-card-title: var(--fw-bold) var(--fs-15) / var(--lh-snug) var(--font-sans); + --type-body: var(--fw-regular) var(--fs-14) / var(--lh-normal) var(--font-sans); + --type-body-sm: var(--fw-regular) var(--fs-13) / var(--lh-normal) var(--font-sans); + --type-nav: var(--fw-regular) var(--fs-14) / 1 var(--font-sans); + --type-table-cell: var(--fw-regular) var(--fs-13) / 1 var(--font-sans); + --type-caption: var(--fw-regular) var(--fs-12) / var(--lh-normal) var(--font-sans); + --type-label: var(--fw-medium) var(--fs-11) / 1 var(--font-sans); + + /* Spacing, 4px grid */ + --space-2: 4px; + --space-4: 8px; + --space-6: 12px; + --space-8: 16px; + --space-12: 24px; + --space-16: 32px; + + /* Frame metrics */ + --sidebar-w: 240px; + --topbar-h: 56px; + --footer-h: 48px; + --nav-item-h: 40px; + --row-h: 40px; + --control-h: 32px; + --control-h-sm: 28px; + --badge-h: 20px; + --meter-h: 6px; + --pad-frame: 16px; + --pad-panel: 16px; + --pad-cell-x: 16px; + --pad-control-x: 12px; + + /* Shape */ + --radius-xs: 2px; + --sv-radius-sm: 4px; + --sv-radius-md: 6px; + --sv-radius-lg: 8px; + --radius-full: 999px; + --border-hairline: 1px solid var(--border-default); + --border-control: 1px solid var(--border-strong); + + /* Effects */ + --shadow-popover: 0 8px 24px rgba(0, 0, 0, 0.55); + --shadow-dialog: 0 24px 64px rgba(0, 0, 0, 0.65); + --focus-ring: 0 0 0 2px var(--accent-ring); + --scrim: rgba(10, 10, 10, 0.72); + + color-scheme: dark; +} + +/* Free keeps Tailwind's preflight, so no global reset here. Pro disabled + preflight and carried its own; in this repo the twelve vendored LiveKit and + shadcn components were authored against preflight, and preflight lives in + @layer base while these rules are unlayered, so the console still wins + without turning anything off. What remains is scoped to .sv-console so it + cannot reach the call UI. */ + +.sv-console { + background: var(--surface-base); + color: var(--text-primary); + font: var(--type-body); + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; +} + +.sv-console a { + color: var(--text-link); + text-decoration: none; +} + +.sv-console a:hover { + color: var(--brand-200); +} + +.sv-console :focus-visible { + outline: none; + box-shadow: var(--focus-ring); +} + +.sv-console ::selection { + background: var(--accent-quiet); + color: var(--brand-100); +} + +.sv-console ::-webkit-scrollbar { + width: 10px; + height: 10px; +} + +.sv-console ::-webkit-scrollbar-thumb { + background: var(--n-800); + border-radius: var(--radius-full); + border: 2px solid transparent; + background-clip: content-box; +} + +.sv-console ::-webkit-scrollbar-track { + background: transparent; +} + +/* ---------- frame ---------- */ + +.fr { + min-height: 100vh; + display: flex; + background: var(--surface-base); + color: var(--text-primary); + font: var(--type-body); +} + +.rail { + width: var(--sidebar-w); + flex: none; + background: var(--surface-sunken); + border-right: 1px solid var(--border-default); + display: flex; + flex-direction: column; + position: sticky; + top: 0; + height: 100vh; +} + +.rail .brand { + height: var(--topbar-h); + display: flex; + align-items: center; + gap: 9px; + padding: 0 var(--pad-frame); + border-bottom: 1px solid var(--border-default); +} + +.rail .mark { + width: 14px; + height: 14px; + background: var(--sv-accent); + transform: rotate(45deg); + border-radius: 1px; + flex: none; +} + +.rail .brand span { + font: var(--fw-bold) var(--fs-15) / 1 var(--font-sans); +} + +.rail .sec { + font: var(--type-label); + letter-spacing: var(--tracking-label); + text-transform: uppercase; + color: var(--text-faint); + padding: 16px var(--pad-frame) 6px; +} + +.rail nav { + display: flex; + flex-direction: column; + padding: 0 8px; +} + +.rail nav a { + height: var(--nav-item-h); + display: flex; + align-items: center; + gap: 10px; + padding: 0 10px; + border-radius: var(--sv-radius-md); + color: var(--text-secondary); + font: var(--type-nav); +} + +.rail nav a:hover { + background: var(--surface-hover); + color: var(--text-primary); +} + +.rail nav a.on { + background: var(--surface-selected); + color: var(--text-primary); +} + +.rail nav a .ct { + margin-left: auto; + font: var(--type-label); + color: var(--text-faint); + font-variant-numeric: tabular-nums; +} + +.rail .scroll { + flex: 1; + overflow-y: auto; + min-height: 0; +} + +.rail .foot { + margin-top: auto; + border-top: 1px solid var(--border-default); + padding: 12px; + flex: none; +} + +.rail .who { + display: flex; + align-items: center; + gap: 10px; + padding: 6px; + border-radius: var(--sv-radius-md); + width: 100%; + background: transparent; + border: 0; + cursor: pointer; + text-align: left; +} + +.rail .who:hover { + background: var(--surface-hover); +} + +.rail .av { + width: 26px; + height: 26px; + border-radius: var(--sv-radius-sm); + background: var(--accent-quiet); + color: var(--accent-quiet-text); + display: flex; + align-items: center; + justify-content: center; + font: var(--fw-bold) var(--fs-12) / 1 var(--font-sans); + flex: none; +} + +.cv { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; +} + +.top { + height: var(--topbar-h); + flex: none; + border-bottom: 1px solid var(--border-default); + display: flex; + align-items: center; + gap: 12px; + padding: 0 var(--pad-frame); + position: sticky; + top: 0; + background: var(--surface-base); + z-index: 5; +} + +.top h1 { + font: var(--type-panel-title); + margin: 0; +} + +.top .sp { + margin-left: auto; + display: flex; + align-items: center; + gap: 8px; +} + +.bd { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; +} + +.ftr { + height: var(--footer-h); + flex: none; + border-top: 1px solid var(--border-default); + background: var(--surface-sunken); + display: flex; + align-items: center; + gap: 14px; + padding: 0 var(--pad-frame); + font: var(--type-caption); + color: var(--text-faint); +} + +/* ---------- primitives ---------- */ + +.lb { + font: var(--type-label); + letter-spacing: var(--tracking-label); + text-transform: uppercase; + color: var(--text-faint); +} + +.num { + font-variant-numeric: tabular-nums; +} + +.mut { + color: var(--text-secondary); +} + +.fnt { + color: var(--text-faint); +} + +.ok { + color: var(--success); +} + +.pnl { + border: 1px solid var(--border-default); + background: var(--surface-base); +} + +.ph { + min-height: 44px; + display: flex; + align-items: center; + gap: 8px; + padding: 0 var(--pad-panel); + border-bottom: 1px solid var(--border-default); + font: var(--type-panel-title); +} + +.ph .meta { + margin-left: auto; + font: var(--type-caption); + color: var(--text-muted); +} + +.pb { + padding: var(--pad-panel); +} + +.bg { + height: var(--badge-h); + display: inline-flex; + align-items: center; + padding: 0 7px; + border-radius: var(--radius-xs); + font: var(--type-label); + white-space: nowrap; + background: var(--surface-selected); + color: var(--text-secondary); +} + +.bg.ok { + background: rgba(154, 177, 117, 0.16); + color: var(--success); +} + +.bg.wn { + background: rgba(231, 201, 148, 0.16); + color: var(--warning); +} + +.bg.bad { + background: rgba(180, 106, 95, 0.16); + color: var(--danger); +} + +.bg.vio { + background: var(--danger-solid); + color: var(--danger-solid-text); +} + +.bg.acc { + background: var(--accent-quiet); + color: var(--accent-quiet-text); +} + +.btn { + height: var(--control-h); + display: inline-flex; + align-items: center; + gap: 6px; + padding: 0 var(--pad-control-x); + border: var(--border-control); + border-radius: var(--sv-radius-md); + background: transparent; + color: var(--text-primary); + font: var(--type-body-sm); + white-space: nowrap; + cursor: pointer; +} + +.btn:hover { + background: var(--surface-hover); +} + +.btn.p { + background: var(--accent-solid); + border-color: var(--accent-solid); + color: #fff; +} + +.btn.p:hover { + background: var(--accent-press); + border-color: var(--accent-press); +} + +.btn.g { + border-color: transparent; + color: var(--text-secondary); +} + +.btn.d { + border-color: var(--danger); + color: var(--danger); +} + +.btn.sm { + height: var(--control-h-sm); + font: var(--type-caption); +} + +.btn:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.inp { + height: 36px; + display: flex; + align-items: center; + gap: 8px; + padding: 0 var(--pad-control-x); + border: var(--border-control); + border-radius: var(--sv-radius-md); + background: transparent; + color: var(--text-primary); + font: var(--type-body-sm); +} + +.inp::placeholder { + color: var(--text-faint); +} + +.tb { + width: 100%; + border-collapse: collapse; +} + +.tb th { + text-align: left; + font: var(--type-label); + letter-spacing: var(--tracking-label); + text-transform: uppercase; + color: var(--text-faint); + padding: 0 var(--pad-cell-x); + height: 36px; + border-bottom: 1px solid var(--border-default); + white-space: nowrap; +} + +.tb td { + font: var(--type-table-cell); + color: var(--text-secondary); + padding: 0 var(--pad-cell-x); + height: var(--row-h); + border-bottom: 1px solid var(--border-default); + white-space: nowrap; + font-variant-numeric: tabular-nums; +} + +.tb tbody tr:hover td { + background: var(--surface-row-hover); +} + +.tb tr.hl td { + background: var(--surface-row-hover); + color: var(--text-primary); +} + +.tb td.pri { + color: var(--text-primary); +} + +.tb td.kept { + color: var(--success); +} + +.tb td.na { + color: var(--text-faint); +} + +.tb tr.clickable { + cursor: pointer; +} + +.scrollx { + overflow-x: auto; + max-width: 100%; +} + +.mtr { + height: var(--meter-h); + border-radius: var(--radius-full); + background: var(--chart-track); + overflow: hidden; + display: flex; +} + +.mtr s { + display: block; + height: 100%; + text-decoration: none; +} + +.stat { + flex: 1; + padding: 14px var(--pad-panel); + border-right: 1px solid var(--border-default); + min-width: 0; +} + +.stat:last-child { + border-right: 0; +} + +.stat b { + display: block; + font: var(--type-metric); + font-variant-numeric: tabular-nums; + margin-bottom: 3px; +} + +.stat i { + display: block; + font: var(--type-body-sm); + font-style: normal; + color: var(--text-secondary); +} + +.stat u { + display: block; + font: var(--type-caption); + text-decoration: none; + color: var(--text-faint); + margin-top: 2px; +} + +.statrow { + display: flex; + flex-wrap: wrap; +} + +.ghost { + border: 1px dotted var(--border-strong); + border-radius: var(--sv-radius-sm); +} + +.ann { + font: var(--type-caption); + color: var(--brand-300); + line-height: var(--lh-normal); +} + +.av2 { + width: 28px; + height: 28px; + flex: none; + border-radius: var(--radius-xs); + display: flex; + align-items: center; + justify-content: center; + font: var(--fw-bold) var(--fs-11) / 1 var(--font-sans); + letter-spacing: 0.02em; + border: 1px solid var(--border-default); +} + +.av2.s1 { + background: var(--brand-900); + color: var(--brand-200); +} + +.av2.s2 { + background: rgba(154, 177, 117, 0.18); + color: var(--success); +} + +.av2.s3 { + background: rgba(231, 201, 148, 0.18); + color: var(--warning); +} + +/* ---------- money loop ---------- */ + +.loop { + display: flex; + align-items: stretch; + flex-wrap: wrap; +} + +.loop .leg { + flex: 1; + padding: 18px var(--pad-panel); + min-width: 150px; +} + +.loop .leg .lb { + display: block; + margin-bottom: 8px; +} + +.loop .leg b { + display: block; + font: var(--type-metric-xl); + font-variant-numeric: tabular-nums; +} + +.loop .leg.kept b { + color: var(--success); +} + +.loop .arrow { + flex: none; + display: flex; + align-items: center; + padding: 0 4px; + color: var(--text-faint); + font-size: 18px; +} + +.disclose { + padding: 10px var(--pad-panel); + border-top: 1px solid var(--border-default); + font: var(--type-caption); + color: var(--text-faint); +} + +/* ---------- layout helpers ---------- */ + +.pad { + padding: var(--pad-frame); + display: flex; + flex-direction: column; + gap: var(--pad-frame); +} + +.grid2 { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--pad-frame); +} + +.grid3 { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: var(--pad-frame); +} + +.split { + display: grid; + grid-template-columns: minmax(0, 1.6fr) minmax(0, 1fr); + gap: var(--pad-frame); + align-items: start; +} + +.row { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.kv { + display: flex; + gap: 12px; + padding: 9px 0; + border-bottom: 1px solid var(--border-subtle); + font: var(--type-body-sm); + align-items: baseline; +} + +.kv:last-child { + border-bottom: 0; +} + +.kv .k { + width: 150px; + flex: none; + color: var(--text-faint); +} + +.kv .v { + color: var(--text-primary); + min-width: 0; + overflow-wrap: anywhere; +} + +.kv .v.mono { + font-family: var(--font-mono); + font-size: var(--fs-12); +} + +@media (max-width: 1000px) { + .split, + .grid2, + .grid3 { + grid-template-columns: 1fr; + } +} + +/* ---------- transcript ---------- */ + +.turns { + display: flex; + flex-direction: column; +} + +.turn { + display: flex; + gap: 12px; + padding: 12px var(--pad-panel); + border-bottom: 1px solid var(--border-subtle); +} + +.turn .who { + width: 70px; + flex: none; + font: var(--type-label); + letter-spacing: var(--tracking-label); + text-transform: uppercase; + color: var(--text-faint); + padding-top: 2px; +} + +.turn .txt { + font: var(--type-body-sm); + color: var(--text-primary); + min-width: 0; +} + +.turn .tm { + margin-left: auto; + flex: none; + font: var(--type-caption); + color: var(--text-faint); + font-variant-numeric: tabular-nums; +} + +/* ---------- receipt ---------- */ + +.rc { + display: flex; + justify-content: space-between; + gap: 12px; + padding: 7px 0; + font: var(--type-body-sm); + font-variant-numeric: tabular-nums; +} + +.rc .k { + color: var(--text-secondary); +} + +.rc.tot { + border-top: 1px solid var(--border-default); + margin-top: 6px; + padding-top: 10px; + font: var(--type-card-title); +} + +.rc.kept { + color: var(--success); +} + +/* ---------- states ---------- */ + +.empty { + padding: 40px var(--pad-panel); + text-align: center; + color: var(--text-muted); + font: var(--type-body-sm); +} + +.empty h2 { + font: var(--type-card-title); + color: var(--text-primary); + margin: 0 0 6px; +} + +.cmd { + display: inline-block; + margin-top: 12px; + padding: 8px 12px; + border: 1px dotted var(--border-strong); + border-radius: var(--sv-radius-sm); + font-family: var(--font-mono); + font-size: var(--fs-12); + color: var(--text-primary); + text-align: left; +} + +.banner { + display: flex; + align-items: center; + gap: 10px; + padding: 10px var(--pad-panel); + border: 1px solid var(--border-default); + border-left: 2px solid var(--warning); + background: var(--surface-raised); + font: var(--type-body-sm); + color: var(--text-secondary); +} + +.banner.bad { + border-left-color: var(--danger); +} + +.banner.planned { + border-left-color: var(--sv-accent); +} + +/* ---------- login ---------- */ + +.login-form { + min-height: 100vh; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + background: var(--surface-sunken); +} + +.login-form h1 { + font: var(--type-card-title); + margin: 0 0 8px; +} + +.login-form input { + width: 280px; + height: 36px; + padding: 0 var(--pad-control-x); + border: var(--border-control); + border-radius: var(--sv-radius-md); + background: var(--surface-base); + color: var(--text-primary); + font: var(--type-body-sm); +} + +.login-form button { + width: 280px; + height: var(--control-h); + border: 1px solid var(--accent-solid); + border-radius: var(--sv-radius-md); + background: var(--accent-solid); + color: #fff; + font: var(--type-body-sm); + cursor: pointer; +} + +.login-error { + color: var(--danger); + font: var(--type-caption); + margin: 0; +} + +/* Classes used by the ported console screens. */ +.stack { + display: flex; + gap: 4px; +} + +.av2.s4 { + background: rgba(180, 106, 95, 0.18); + color: var(--danger); +} + +.chartbox { + width: 100%; +} + +/* ---------- in-page Pro lock ---------- */ +/* The console's real sales surface. The four locked routes are roadmap + previews; these are shipped Pro features shown in place and greyed, so the + absence is felt exactly where the value would appear. */ + +.sv-console .lk { + position: relative; + border: 1px dashed var(--border-strong); + border-radius: var(--sv-radius-md); + background: var(--surface-sunken); + overflow: hidden; +} + +.sv-console .lk-under { + opacity: 0.28; + filter: grayscale(1); + pointer-events: none; + user-select: none; +} + +.sv-console .lk-over { + display: flex; + align-items: center; + gap: var(--space-8); + padding: var(--space-12) var(--pad-panel); +} + +.sv-console .lk-under + .lk-over { + position: absolute; + inset: 0; + justify-content: center; + background: linear-gradient(180deg, rgba(10, 10, 10, 0.35), rgba(10, 10, 10, 0.72)); +} + +.sv-console .lk.inline .lk-over { + padding: var(--space-4) var(--space-8); +} + +.sv-console .lk-tag { + flex: none; + height: var(--badge-h); + display: inline-flex; + align-items: center; + padding: 0 var(--space-8); + border-radius: var(--sv-radius-sm); + background: var(--sv-accent); + color: var(--text-on-accent); + font: var(--type-label); + letter-spacing: var(--tracking-label); + text-transform: uppercase; +} + +.sv-console .lk-what { + color: var(--text-secondary); + font: var(--type-body-sm); +} + +.sv-console .lk-link { + margin-left: auto; + flex: none; + color: var(--text-link); + font: var(--type-body-sm); +} + +.sv-console .lk.inline .lk-link { + margin-left: var(--space-8); +} + +/* ---------- locked rail item ---------- */ + +.sv-console .rail nav a.lkd { + color: var(--text-muted); +} + +.sv-console .rail nav a.lkd:hover { + color: var(--text-secondary); +} + +.sv-console .nav-lock { + margin-left: auto; + flex: none; + height: 16px; + display: inline-flex; + align-items: center; + padding: 0 6px; + border: 1px solid var(--border-strong); + border-radius: var(--sv-radius-sm); + color: var(--sv-accent); + font: var(--type-caption); + letter-spacing: var(--tracking-label); +} diff --git a/frontend/src/index.css b/frontend/src/index.css index 4b7f771..bea4432 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,5 +1,11 @@ @import "tailwindcss"; @import "tw-animate-css"; +/* Geist was already a dependency and imported nowhere. ShipVoice Pro pulls it + from fonts.googleapis.com, which would put a third-party request on every + deployed console for a font sitting in node_modules. Imported here rather + than from main.tsx because the package ships no type declarations, and a + side-effect import of it fails the build under this tsconfig. */ +@import "@fontsource-variable/geist/index.css"; @custom-variant dark (&:is(.dark *)); diff --git a/frontend/src/lib/format.ts b/frontend/src/lib/format.ts new file mode 100644 index 0000000..413c096 --- /dev/null +++ b/frontend/src/lib/format.ts @@ -0,0 +1,23 @@ +// Display formatters. +// +// These live outside components/ds.tsx because eslint's react-refresh rule +// requires a module to export only components. ShipVoice Pro keeps them in its +// ds module; Pro has no eslint, free lints everything. + +/** + * Money, or an explicit dash. Never renders a zero for an unknown value. + * + * Free has no cost data at all, so this exists for the greyed placeholders + * behind LockedBlock. A "$0.00" there would read as a measured free call. + */ +export function money(value: number | null | undefined): string { + if (value == null) return "-"; + return `$${value.toFixed(2)}`; +} + +export function duration(seconds: number | null | undefined): string { + if (seconds == null) return "-"; + const m = Math.floor(seconds / 60); + const s = Math.round(seconds % 60); + return `${m}m ${String(s).padStart(2, "0")}s`; +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index bef5202..baaeff3 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,10 +1,14 @@ -import { StrictMode } from 'react' -import { createRoot } from 'react-dom/client' -import './index.css' -import App from './App.tsx' +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { BrowserRouter } from "react-router"; +import "./index.css"; +import "./console.css"; +import App from "./App.tsx"; -createRoot(document.getElementById('root')!).render( +createRoot(document.getElementById("root")!).render( - + + + , -) +); diff --git a/frontend/src/pages/AgentDetail.tsx b/frontend/src/pages/AgentDetail.tsx new file mode 100644 index 0000000..77a455e --- /dev/null +++ b/frontend/src/pages/AgentDetail.tsx @@ -0,0 +1,177 @@ +import { useEffect, useState } from "react"; +import { Link, useParams } from "react-router"; +import { ApiError, listAgents } from "../api"; +import type { AgentSummary } from "../types"; +import { Ann, TopBar } from "../components/AppShell"; +import { TestCall } from "../components/TestCall"; +import { Badge, KV, Panel } from "../components/ds"; +import { AGENT_NAME } from "../lib/token-source"; + +// One agent, described only from what this deployment can answer for, and every +// row naming the file or env var that owns the value. That ownership line is +// the point of the page: nothing here is editable from the console, so a reader +// who wants to change something needs to know where it actually lives. + +/** The owning env var or file, under the value it owns. */ +function Owner({ children }: { children: string }) { + return ( + + {children} + + ); +} + +function Dash() { + return -; +} + +export function AgentDetail() { + const { slug } = useParams<{ slug: string }>(); + const [agents, setAgents] = useState(null); + const [forbidden, setForbidden] = useState(false); + const [error, setError] = useState(false); + + useEffect(() => { + let live = true; + listAgents() + .then((r) => { + if (!live) return; + setAgents(r.agents); + }) + .catch((e: unknown) => { + if (!live) return; + if (e instanceof ApiError && e.isExpiredSession) return; + if (e instanceof ApiError && e.isForbidden) setForbidden(true); + else setError(true); + }); + return () => { + live = false; + }; + }, []); + + if (forbidden) { + return ( +
+ +
+

This account is not an administrator

+

+ The backend is reachable and answered. Agent detail is admin-only, and this account + does not have is_superuser set. +

+
+
+
+ ); + } + + if (error) { + return ( +
+
+ API unreachable + Could not load this agent. An agent already running is unaffected. +
+
+ ); + } + + if (agents === null) { + return
Loading agent…
; + } + + const agent = agents.find((a) => a.slug === slug); + + if (!agent) { + return ( +
+ +
+

No agent named "{slug}"

+

+ This deployment does not run one under that name. The name may have changed, or the + link is stale. +

+ + ← Back to Agents + +
+
+
+ ); + } + + // Three places declare this name and LiveKit matches it exactly: the worker + // registers under it, the backend reports it, and this browser dispatches it. + // The browser can only compare two of the three, so it compares those. + const nameMismatch = agent.agent_name !== AGENT_NAME; + + return ( + <> + Active : Idle} + meta={agent.business_name ?? agent.slug} + /> + +
+ {nameMismatch && ( +
+ names differ + + The backend declares {agent.agent_name}{" "} + and this browser dispatches{" "} + {AGENT_NAME}. LiveKit matches + the name exactly, so a test call from here will not reach the worker until they agree. + +
+ )} + +
+ + + + +
+ + + {agent.agent_name} + AGENT_NAME in agent/.env, the name the worker registers under + + + {AGENT_NAME} + VITE_AGENT_NAME in frontend/.env, the name this browser asks for + + + {agent.business_name ?? } + BUSINESS_NAME in backend/.env, spoken in the greeting + + + {agent.prompt_path} + the file the worker reads its instructions from + + + {agent.active ? "active" : "idle"} + this deployment declares one agent and always runs it + + + + + {agent.stt ?? } + {agent.llm ?? } + {agent.tts ?? } +
+ + Declared in the worker source at agent/src/agent.py, not read back from the running + process. Swapping a provider is a code change and a worker restart, not a setting + here. + +
+
+
+
+
+ + ); +} diff --git a/frontend/src/pages/Agents.tsx b/frontend/src/pages/Agents.tsx new file mode 100644 index 0000000..2cb99fc --- /dev/null +++ b/frontend/src/pages/Agents.tsx @@ -0,0 +1,171 @@ +import { useEffect, useState } from "react"; +import { Link } from "react-router"; +import { ApiError, listAgents } from "../api"; +import type { AgentSummary } from "../types"; +import { Ann, TopBar } from "../components/AppShell"; +import { Badge, Button, LockedBlock, Panel } from "../components/ds"; + +// Free runs one worker serving one agent, selected by AGENT_NAME, so this table +// has one row. Several agents under one account is ShipVoice Pro. +// +// Every column here is something the backend can actually answer for. The +// column Pro fills from call aggregation stays a dash rather than a zero, +// because this deployment does not roll calls up by agent and a zero would read +// as a measured "no calls". + +type Load = "loading" | "ok" | "forbidden" | "missing" | "error"; + +/** A value the backend did not give us. Never a zero, never a guess. */ +function Cell({ value }: { value: string | null }) { + if (!value) return -; + return {value}; +} + +function AgentTable({ agents }: { agents: AgentSummary[] }) { + return ( + +
+ + + + + + + + + + + + + {agents.map((a) => ( + + + + + + + + + + + ))} + +
AgentBusinessSTTLLMTTSPromptCalls · 7dState
+ {a.agent_name} + {a.prompt_path}- + {a.active ? Active : Idle} +
+
+
+ + Calls in the last 7 days needs per-agent aggregation. The call log records which agent + answered, but this backend does not roll it up by agent, so the column stays a dash. A dash + means unmeasured, never zero. + +
+
+ ); +} + +export function Agents() { + const [agents, setAgents] = useState([]); + const [load, setLoad] = useState("loading"); + + useEffect(() => { + let live = true; + listAgents() + .then((r) => { + if (!live) return; + setAgents(r.agents); + setLoad("ok"); + }) + .catch((e: unknown) => { + if (!live) return; + // 401 is handled globally by dropping back to the login form, so it is + // not an outage and must not be drawn as one. + if (e instanceof ApiError && e.isExpiredSession) return; + if (e instanceof ApiError && e.isForbidden) setLoad("forbidden"); + else if (e instanceof ApiError && e.isMissing) setLoad("missing"); + else setLoad("error"); + }); + return () => { + live = false; + }; + }, []); + + let content; + if (load === "forbidden") { + content = ( + +
+

This account is not an administrator

+

+ The backend is reachable and answered. The agent list is admin-only, and this account + does not have is_superuser set. +

+ + UPDATE users SET is_superuser = true WHERE email = 'your@email'; + +
+
+ ); + } else if (load === "missing") { + content = ( +
+ not wired + + This backend has no /api/v1/agents route, so the agent it runs cannot be described here. + The agent itself is unaffected. + +
+ ); + } else if (load === "error") { + content = ( +
+ API unreachable + The agent list is unknown, not empty. An agent already running is unaffected. +
+ ); + } else if (load === "loading") { + content =
Loading agents…
; + } else if (agents.length === 0) { + content = ( + +
+

No agent yet

+

+ This deployment is not running one. Describe the agent you want in Claude Code and it + writes the prompt, the worker config, and the env the three services need. +

+ /setup +
+
+ ); + } else { + content = ( + <> + + + + ); + } + + return ( + <> + + New agent + + } + /> +
{content}
+ + ); +} diff --git a/frontend/src/pages/CallDetail.tsx b/frontend/src/pages/CallDetail.tsx new file mode 100644 index 0000000..74abc93 --- /dev/null +++ b/frontend/src/pages/CallDetail.tsx @@ -0,0 +1,270 @@ +import { useEffect, useState } from "react"; +import { useNavigate, useParams } from "react-router"; +import { ApiError, deleteCall, getCall } from "../api"; +import { Ann, TopBar } from "../components/AppShell"; +import { Badge, Button, KV, LockedBlock } from "../components/ds"; +import { duration } from "../lib/format"; +import type { CallDetailResponse } from "../types"; + +// One call, in full: what was said, when, and by whom. The receipt that would +// sit above the transcript is the ShipVoice Pro surface, so it is drawn here and +// locked rather than quietly dropped. + +/** Offset from the start of the call. Absolute clock time is on the tooltip. */ +function elapsed(startedAt: string, at: string): string { + const ms = new Date(at).getTime() - new Date(startedAt).getTime(); + if (!Number.isFinite(ms)) return "-"; + const total = Math.max(0, Math.round(ms / 1000)); + return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, "0")}`; +} + +function Missing() { + return -; +} + +export function CallDetail() { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const [data, setData] = useState(null); + const [error, setError] = useState(null); + // A browser confirm() blocks automation and steals the page, so the confirm + // step lives in component state and renders inline in the topbar. + const [confirming, setConfirming] = useState(false); + const [deleting, setDeleting] = useState(false); + const [deleteError, setDeleteError] = useState(null); + + useEffect(() => { + if (!id) return; + let live = true; + getCall(id) + .then((d) => { + if (live) setData(d); + }) + .catch((e: unknown) => { + if (!live) return; + if (e instanceof ApiError && e.isExpiredSession) return; + if (e instanceof ApiError && e.isMissing) { + setError( + "The backend answered 404. Either this call is not in the log, or the call log is not wired up in this checkout yet.", + ); + } else { + setError("Could not reach the backend for this call. Your agent is unaffected."); + } + }); + return () => { + live = false; + }; + }, [id]); + + const onDelete = (): void => { + if (!id) return; + setDeleting(true); + setDeleteError(null); + deleteCall(id) + .then(() => navigate("/calls")) + .catch((e: unknown) => { + setDeleting(false); + setDeleteError( + e instanceof ApiError && e.isMissing + ? "This call is already gone from the log." + : "Could not delete this call. Nothing was removed.", + ); + }); + }; + + if (error) { + return ( + <> + +
+
+ error + {error} +
+
+ + ); + } + + if (!data) { + return ( + <> + +
Loading this call
+ + ); + } + + const { call, transcript } = data; + const agentTurns = transcript.filter((t) => t.role === "agent").length; + const callerTurns = transcript.filter((t) => t.role === "user").length; + + return ( + <> + {call.caller ?? call.room_name}} + badge={ + call.status === "completed" ? ( + Completed + ) : call.status === "failed" ? ( + Failed + ) : ( + Active + ) + } + meta={`${new Date(call.started_at).toLocaleString()} · ${duration(call.duration_seconds)} · ${ + call.agent_name ?? "unknown agent" + } · ${call.channel === "sip" ? "phone" : "web"}`} + actions={ + confirming ? ( + <> + + Delete this call and its transcript? + + + + + ) : ( + + ) + } + /> + + {deleteError && ( +
+
+ error + {deleteError} +
+
+ )} + + {/* Measured, from the record this backend actually keeps. */} +
+
+ + {duration(call.duration_seconds)} + + Duration + {call.ended_at ? "start to hangup" : "still in flight"} +
+
+ {call.turn_count.toLocaleString()} + Turns + as recorded on the call +
+
+ {agentTurns.toLocaleString()} + Agent turns + counted in the transcript +
+
+ {callerTurns.toLocaleString()} + Caller turns + counted in the transcript +
+
+ +
+ {/* Where the receipt goes. Seven cells, all of them locked. */} + +
+ {["STT", "LLM", "TTS", "Telephony", "Cost", "Billed", "Kept"].map((cell) => ( +
+ - + {cell} +
+ ))} +
+
+
+ +
+
+
+ Transcript + + {transcript.length.toLocaleString()} turns · times are from the start of the call + +
+ {transcript.length === 0 ? ( +
+ No turns were recorded for this call. That is the record, not a loading state. +
+ ) : ( +
+ {transcript.map((t) => ( +
+ {t.role === "agent" ? "Agent" : "Caller"} + {t.text} + + {elapsed(call.started_at, t.spoken_at)} + +
+ ))} +
+ )} +
+ +
+
+ Callas the agent reported it +
+
+ + {call.room_name} + + + {call.caller ?? } + + {call.channel === "sip" ? "Phone" : "Web"} + {call.agent_name ?? } + {call.business_name ?? } + {new Date(call.started_at).toLocaleString()} + {call.ended_at ? new Date(call.ended_at).toLocaleString() : } + + {call.id} + +
+ +
+ Not on this recordby design +
+
+

+ This repo records what happened on the call. What each minute cost, split by provider, and what was + left after you billed it on, is ShipVoice Pro. +

+ + A dash above means the agent never reported that field. It is an absence, not an empty string. + +
+
+
+ + ); +} diff --git a/frontend/src/pages/CallLogs.tsx b/frontend/src/pages/CallLogs.tsx new file mode 100644 index 0000000..b19d40d --- /dev/null +++ b/frontend/src/pages/CallLogs.tsx @@ -0,0 +1,271 @@ +import { useEffect, useState } from "react"; +import { Link, useNavigate } from "react-router"; +import { API_BASE, ApiError, listCalls } from "../api"; +import { Ann, TopBar } from "../components/AppShell"; +import { Badge, Button, LockedBlock } from "../components/ds"; +import { duration } from "../lib/format"; +import type { CallRead, CallStatus } from "../types"; + +// Every call the agent reported, filterable. The two columns on the right are +// the ones this repo cannot fill: they are drawn in place and locked, so the +// absence sits exactly where the number would be rather than being left out. + +const CHANNELS: { key: string; label: string }[] = [ + { key: "all", label: "Any channel" }, + { key: "web", label: "Web" }, + { key: "sip", label: "Phone" }, +]; + +const STATUSES: { key: string; label: string; tone: "neutral" | "success" | "violation" }[] = [ + { key: "all", label: "All", tone: "neutral" }, + { key: "active", label: "Active", tone: "neutral" }, + { key: "completed", label: "Completed", tone: "success" }, + { key: "failed", label: "Failed", tone: "violation" }, +]; + +function statusBadge(status: CallStatus) { + if (status === "completed") return Completed; + if (status === "failed") return Failed; + return Active; +} + +export function CallLogs() { + const navigate = useNavigate(); + const [calls, setCalls] = useState([]); + const [total, setTotal] = useState(0); + const [channel, setChannel] = useState("all"); + const [status, setStatus] = useState("all"); + const [query, setQuery] = useState(""); + const [state, setState] = useState<"loading" | "ok" | "unwired">("loading"); + + useEffect(() => { + let live = true; + // No setState here. React 19's lint rule rejects a synchronous setState in + // an effect body, and resetting to "loading" on a filter change would blank + // rows that are about to be replaced anyway. + listCalls({ limit: 50, channel, status }) + .then((r) => { + if (!live) return; + setCalls(r.calls); + setTotal(r.total); + setState("ok"); + }) + .catch((e: unknown) => { + if (!live) return; + // A lapsed token is not an outage: App drops back to the login form. + if (e instanceof ApiError && e.isExpiredSession) return; + setState("unwired"); + }); + return () => { + live = false; + }; + }, [channel, status]); + + const q = query.trim().toLowerCase(); + const shown = q + ? calls.filter((c) => + [c.caller, c.room_name, c.agent_name, c.business_name] + .filter((v): v is string => v != null) + .join(" ") + .toLowerCase() + .includes(q), + ) + : calls; + + return ( + <> + No call log : this deployment + } + actions={ + + } + /> + +
+ setQuery(e.target.value)} + aria-label="Search calls" + /> + {CHANNELS.map((ch) => ( + + ))} + + {STATUSES.map((chip) => ( + + ))} +
+ + {state === "unwired" ? ( +
+
+
+

The call log is not wired up in this checkout yet

+

+ This list is unknown, not empty. The voice path is unaffected: the agent answers and speaks whether + or not this console can see it. +

+ GET {API_BASE}/api/v1/calls/ +
+
+
+ ) : state === "loading" ? ( +
Loading calls
+ ) : total === 0 ? ( +
+
+
+

No calls yet

+

+ The backend answered with an empty log, so this is a measured zero. Every call the agent reports + lands here with its caller, duration, and transcript. +

+ Open Agents, pick your agent, and start a test call +
+
+
+ ) : shown.length === 0 ? ( + // The log is not empty, the search is. Say which one, and say how many + // rows the current filters did load. +
+ No call matches that search. {calls.length.toLocaleString()} of {total.toLocaleString()} are loaded under + the current filters. +
+ ) : ( + <> +
+ + + + + + + + + + + + + + + {shown.map((c, i) => ( + navigate(`/calls/${c.id}`)}> + {/* A web call has no caller id. The room name is what was + actually recorded, so it stands in rather than a dash. */} + + + + + + + {/* The Cost and Kept columns, drawn once across every row. + One lock over the whole region beats repeating the same + upsell on all fifty lines, and it still lands in the + exact cells the numbers would occupy. */} + {i === 0 && ( + + )} + + ))} + +
CallerChannelStartedDurationTurnsStatusCostKept
+ e.stopPropagation()} + > + {c.caller ?? c.room_name} + + {c.channel === "sip" ? "Phone" : "Web"} + {new Date(c.started_at).toLocaleString([], { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + })} + + {duration(c.duration_seconds)} + {c.turn_count}{statusBadge(c.status)} e.stopPropagation()} + style={{ + whiteSpace: "normal", + minWidth: 320, + verticalAlign: "middle", + background: "var(--surface-base)", + }} + > + +
+
+ +
+ + {shown.length.toLocaleString()} of {total.toLocaleString()} + + + A dash means unmeasured, never zero. A call still in flight has no duration yet. + +
+ + )} + + ); +} diff --git a/frontend/src/pages/Overview.tsx b/frontend/src/pages/Overview.tsx new file mode 100644 index 0000000..7987b06 --- /dev/null +++ b/frontend/src/pages/Overview.tsx @@ -0,0 +1,327 @@ +import { useEffect, useState } from "react"; +import { Link } from "react-router"; +import { API_BASE, ApiError, getSummary, listCalls } from "../api"; +import { Ann, TopBar } from "../components/AppShell"; +import { Badge, Button, DottedLineChart, LockedBlock } from "../components/ds"; +import { duration } from "../lib/format"; +import type { CallRead, CallSummaryResponse } from "../types"; + +// The one page that has to earn the console. It shows the buyer their own real +// call activity, and directly below it, greyed out, the money they cannot see. +// The contrast is the pitch: free records what happened on a call, ShipVoice Pro +// records what it cost and what you kept. + +type Load = "loading" | "ok" | "unwired" | "refused"; + +function statusBadge(c: CallRead) { + if (c.status === "completed") return Completed; + if (c.status === "failed") return Failed; + return Active; +} + +/** The locked money strip. Rendered under the scrim, so every value is a dash. */ +function MoneyLoop() { + return ( + +
+
+ Provider cost + - +
+
+
+ Billed + - +
+
+
+ Kept + - +
+
+
+ ); +} + +export function Overview() { + const [summary, setSummary] = useState(null); + const [calls, setCalls] = useState([]); + const [total, setTotal] = useState(0); + const [state, setState] = useState("loading"); + const [nonce, setNonce] = useState(0); + + useEffect(() => { + let live = true; + + // listCalls decides the state of the page. A 404 or a dead socket is the + // difference between "this checkout has no call log" and "you have no calls", + // and conflating them sends the reader to fix the wrong thing. + listCalls({ limit: 6 }) + .then((r) => { + if (!live) return; + setCalls(r.calls); + setTotal(r.total); + setState("ok"); + }) + .catch((e: unknown) => { + if (!live) return; + // A 401 is handled globally: App drops back to the login form. + if (e instanceof ApiError && e.isExpiredSession) return; + if (e instanceof ApiError && e.isForbidden) setState("refused"); + else setState("unwired"); + }); + + // The rollup is fetched separately on purpose. If it fails while the list + // works, the totals below render dashes instead of taking the whole page + // down, and a dash is the honest answer for a number nobody measured. + getSummary() + .then((s) => { + if (live) setSummary(s); + }) + .catch(() => undefined); + + return () => { + live = false; + }; + }, [nonce]); + + const refresh = () => setNonce((n) => n + 1); + + if (state === "loading") { + return ( + <> + +
Loading your call activity
+ + ); + } + + // The backend answered and said no. That is an authorisation answer, not an + // outage, and saying "unreachable" here would send the reader to check Docker. + if (state === "refused") { + return ( + <> + Refused} + actions={ + + } + /> +
+
+
+

The backend refused this account

+

+ It is reachable and it answered. This sign-in is not allowed to read calls, so the fix is the + account, not the stack. +

+ GET {API_BASE}/api/v1/calls/ responded 403 +
+
+
+ + ); + } + + // Zero state one: there is no call log on this checkout to be empty. + if (state === "unwired") { + return ( + <> + No call log} + actions={ + + } + /> +
+
+
+

The call log is not wired up in this checkout yet

+

+ The console asked the backend for calls and got no list back. Nothing is broken in the voice path: + your agent still answers, speaks, and hangs up without this page. +

+ GET {API_BASE}/api/v1/calls/ +
+
+ + This is not the same screen as an empty log. An empty log is a measured zero. This one is unknown, so + it does not show you a zero. + +
+ + ); + } + + // Zero state two: the log exists, answered, and is genuinely empty. + if (total === 0) { + return ( + <> + No calls yet} + actions={ + + } + /> +
+
+
+

No calls yet

+

+ The backend answered with an empty log, so this is a real zero. Calls land here the moment the agent + reports one, with the caller, the duration, and the full transcript. +

+ Open Agents, pick your agent, and start a test call +
+
+ + What the call cost you, and what you kept after billing it on, is ShipVoice Pro. +
+ + ); + } + + const avgTurns = + summary && summary.total_calls > 0 ? (summary.total_turns / summary.total_calls).toFixed(1) : null; + + // A call still in flight has no duration yet. Drop it rather than plotting a + // zero, which would read as an instant call. xLabels stays aligned because + // both are derived from this same filtered list. + const measured = calls.filter( + (c): c is CallRead & { duration_seconds: number } => c.duration_seconds != null, + ); + + return ( + <> + this deployment} + actions={ + + } + /> + + {/* Measured, from your own backend. Every one of these is countable. */} +
+
+ {total.toLocaleString()} + Calls + recorded in the log +
+
+ + {summary ? Math.round(summary.total_minutes).toLocaleString() : "-"} + + Minutes + {summary ? "across every call" : "rollup unavailable"} +
+
+ + {summary ? summary.total_turns.toLocaleString() : "-"} + + Turns + {summary ? "spoken, both sides" : "rollup unavailable"} +
+
+ {avgTurns ?? "-"} + Turns per call + {avgTurns ? "average" : "needs the rollup"} +
+
+ +
+ {/* Directly under the real numbers, greyed: the numbers you do not get. */} + + +
+
+
+ Recent calls + + {total.toLocaleString()} recorded · View all → + +
+
+ + + + + + + + + + + + + + {calls.map((c) => ( + + + + + + + + + + ))} + +
StartedCallerChannelAgentDurationTurnsStatus
+ + {new Date(c.started_at).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + })} + + {c.caller ?? "-"}{c.channel === "sip" ? "Phone" : "Web"}{c.agent_name ?? "-"} + {duration(c.duration_seconds)} + {c.turn_count}{statusBadge(c)}
+
+
+ A dash means unmeasured, never zero. A call still in flight has no duration to show yet. +
+
+ +
+
+ Call length + minutes, oldest left +
+
+ {measured.length > 0 ? ( + c.duration_seconds / 60).reverse()} + height={132} + label="Duration of each recent call, in minutes" + showAxis + xLabels={measured + .map((c) => + new Date(c.started_at).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }), + ) + .reverse()} + /> + ) : ( +

+ No recent call has finished, so there is no length to plot. An unfinished call is not a zero + minute call. +

+ )} +
+
+
+
+ + ); +} diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx new file mode 100644 index 0000000..f122b57 --- /dev/null +++ b/frontend/src/pages/Settings.tsx @@ -0,0 +1,226 @@ +import { useEffect, useState, type ReactNode } from "react"; +import { API_BASE, ApiError, getSummary, listAgents } from "../api"; +import { Ann, TopBar } from "../components/AppShell"; +import { Badge, Button, LockedBlock, Panel } from "../components/ds"; +import type { AgentSummary } from "../types"; + +// One page, stacked, mostly a mirror. Every row names the env var or the file +// that owns it, and the console writes nothing here. There are no inputs on +// this page on purpose: a form that saved a value the worker reads from its +// own process environment would be a lie the moment the worker restarted. +// +// The rule that shapes every row: say what you can observe, and say plainly +// when you cannot. No green "configured" badge for a value the browser never +// sees, no zero standing in for an unknown. + +/** + * One mirrored value. A value the console cannot read renders a dash on a + * '.na' cell, never a zero and never a plausible-looking default. + */ +function Row({ k, v, owner }: { k: string; v: ReactNode; owner: string }) { + const missing = v == null || v === ""; + return ( + + + {k} + + {missing ? "-" : v} + + {owner} + + + ); +} + +/** + * The honest answers to "is the backend there": reachable, plus the four ways + * the probe can fail. Collapsing these into a boolean sends people off to + * restart Docker when the real fix is to make their account a superuser. + */ +type Reach = "checking" | "ok" | "unreachable" | "forbidden" | "missing" | "refused"; + +const REACH_TEXT: Record = { + checking: "Checking...", + ok: "Reachable, and it answered the call summary", + unreachable: "No answer, or it returned a server error", + forbidden: "Reachable, but this account is not a superuser", + missing: "Reachable, but the call slice is not wired on this checkout", + refused: "Reachable, but it refused the request", +}; + +function classify(err: unknown): Reach { + if (!(err instanceof ApiError)) return "refused"; + if (err.isUnreachable) return "unreachable"; + if (err.isForbidden) return "forbidden"; + if (err.isMissing) return "missing"; + return "refused"; +} + +const NOTE_STYLE = { font: "var(--type-body-sm)", margin: 0, color: "var(--text-secondary)" }; + +export function Settings({ onLogout }: { onLogout?: () => void }) { + const [agent, setAgent] = useState(null); + const [reach, setReach] = useState("checking"); + + useEffect(() => { + let live = true; + + // Identity comes from the agents endpoint, which reads the deployment's + // own config. Reachability is probed separately so a 403 on one does not + // get reported as the backend being down. + listAgents() + .then((r) => live && setAgent(r.agents[0] ?? null)) + .catch(() => undefined); + + getSummary() + .then(() => live && setReach("ok")) + .catch((e: unknown) => live && setReach(classify(e))); + + return () => { + live = false; + }; + }, []); + + return ( + <> + Deployment-wide} + actions={ + <> + + + + } + /> + +
+ +
+ + + + + + + +
+
+
+

+ The dispatch name is the one baked into this build; the agent name is the one the + backend reports. They must be identical strings. If they differ, LiveKit never + dispatches the worker and the call connects to silence with no error anywhere. +

+
+
+ + +
+ + + + + + + +
+
+
+

+ These are what the worker file declares, not a live reading. The session is built with + them as constants and the backend cannot see the agent process, so nothing here proves + which models a running worker actually loaded. The Cartesia call passes no model, so + the plugin default applies: naming one here would be a guess. +

+
+
+ + +
+ + + + + +
+
+
+

+ The call log requires a superuser account. Being signed in is not enough. Registration + can be reopened, and a self-registered account is a valid bearer of a valid token, so + every surface that exposes call content checks the superuser flag rather than the + session alone. +

+
+
+ + +
+ + + + + +
+
+
+

+ Reachability is one request to the call summary, run when this page loaded. It is not + a health monitor and it does not re-poll. If the origin above is missing from the + backend's allowed list, the browser blocks the request before the backend ever sees + it, and that reads here as no answer. +

+
+
+ + + + + + Compliance} meta="read this one"> + +

+ This starter has no compliance gate. Nothing in it checks consent, a suppression list, + or the local calling window, and it will dial whoever you point it at. There is no + setting on this page that changes that, because there is no code behind one. You are the + caller of record and the liability is yours. +

+
+ + + Every row names the env var or file that owns it. The console reads; the terminal writes. + Changing a value here means editing the file it points at. + +
+ + ); +} diff --git a/frontend/src/pages/locked/Campaigns.tsx b/frontend/src/pages/locked/Campaigns.tsx new file mode 100644 index 0000000..3750f34 --- /dev/null +++ b/frontend/src/pages/locked/Campaigns.tsx @@ -0,0 +1,15 @@ +import { LockedPage } from "./LockedPage"; + +export function Campaigns() { + return ( + + ); +} diff --git a/frontend/src/pages/locked/Channels.tsx b/frontend/src/pages/locked/Channels.tsx new file mode 100644 index 0000000..318c244 --- /dev/null +++ b/frontend/src/pages/locked/Channels.tsx @@ -0,0 +1,15 @@ +import { LockedPage } from "./LockedPage"; + +export function Channels() { + return ( + + ); +} diff --git a/frontend/src/pages/locked/Customers.tsx b/frontend/src/pages/locked/Customers.tsx new file mode 100644 index 0000000..ca36c6d --- /dev/null +++ b/frontend/src/pages/locked/Customers.tsx @@ -0,0 +1,14 @@ +import { LockedPage } from "./LockedPage"; + +export function Customers() { + return ( + + ); +} diff --git a/frontend/src/pages/locked/Evaluations.tsx b/frontend/src/pages/locked/Evaluations.tsx new file mode 100644 index 0000000..2ddf0af --- /dev/null +++ b/frontend/src/pages/locked/Evaluations.tsx @@ -0,0 +1,15 @@ +import { LockedPage } from "./LockedPage"; + +export function Evaluations() { + return ( + + ); +} diff --git a/frontend/src/pages/locked/LockedPage.tsx b/frontend/src/pages/locked/LockedPage.tsx new file mode 100644 index 0000000..546efd8 --- /dev/null +++ b/frontend/src/pages/locked/LockedPage.tsx @@ -0,0 +1,48 @@ +// The shared frame for the four locked routes. +// +// These are ShipVoice Pro surfaces that Pro has DESIGNED and NOT BACKED: in the +// Pro repo each of these four pages opens with a comment saying its figures are +// the design's sample data and the surface has no backend. So they are roadmap +// previews, not a paywall, and this page must never imply that paying turns one +// of them on, because paying does not. A buyer who catches one overclaim stops +// believing the locks that are real. +// +// The locks that ARE real live on the working pages, via LockedBlock, over +// things Pro has actually shipped: per-call cost metered by provider, the +// cost / billed / kept receipt, per-minute metered billing, generating and +// validating an agent from one sentence, compliance gates that fail closed. +// +// No sample rows, no invented stat strip, no screenshot: there is nothing +// truthful to show yet, and an empty table would read as a broken feature +// rather than an unbuilt one. +import { TopBar } from "../../components/AppShell"; +import { Badge, PRO_URL } from "../../components/ds"; + +export function LockedPage({ title, sentence }: { title: string; sentence: string }) { + return ( + <> + ShipVoice Pro} + meta="roadmap preview" + /> + + + ); +} diff --git a/frontend/src/pages/locked/locked.test.tsx b/frontend/src/pages/locked/locked.test.tsx new file mode 100644 index 0000000..4522f17 --- /dev/null +++ b/frontend/src/pages/locked/locked.test.tsx @@ -0,0 +1,56 @@ +// These four routes are the only place in the console where a public, free repo +// talks about the paid product at length, so the copy is policed by test rather +// than by care. Two rules: +// +// 1. Nothing on the banned list reaches the DOM. This repo is public and +// advertises a paid product, so carrier names, unshipped feature names and +// any claim of a template count stay out. +// 2. Nothing sells these four as purchasable. Pro has designed them and not +// built them, so "unlock" or "buy to access" would be a lie, and a buyer +// who catches one overclaim stops believing the locks that are real. +import type { ComponentType } from "react"; +import { render } from "@testing-library/react"; +import { MemoryRouter } from "react-router"; +import { expect, test } from "vitest"; +import { Campaigns } from "./Campaigns"; +import { Channels } from "./Channels"; +import { Customers } from "./Customers"; +import { Evaluations } from "./Evaluations"; + +const BANNED = /\bBYO\b|0%|\bEVA\b|verify-before-speak|recordings?\b|\bTwilio\b|\bTelnyx\b|\d+\s*templates?/i; +const PAYWALL = /unlock|upgrade to (get|use) this|buy .* to access/i; + +const PAGES = [ + ["Campaigns", Campaigns], + ["Channels", Channels], + ["Customers", Customers], + ["Evaluations", Evaluations], +] as const; + +function renderPage(Page: ComponentType): HTMLElement { + const { container } = render( + + + , + ); + return container; +} + +for (const [name, Page] of PAGES) { + test(`${name} says nothing on the banned list`, () => { + expect(renderPage(Page).textContent ?? "").not.toMatch(BANNED); + }); + + // Defensive: a grep over the source cannot read a PNG. If anyone ever adds a + // screenshot here, its alt text is still policed by the same list. + test(`${name} keeps banned words out of every image alt`, () => { + // Array.from, not for..of: this tsconfig's lib has DOM without DOM.Iterable. + for (const img of Array.from(renderPage(Page).querySelectorAll("img"))) { + expect(img.getAttribute("alt") ?? "").not.toMatch(BANNED); + } + }); + + test(`${name} does not sell itself as purchasable`, () => { + expect(renderPage(Page).textContent ?? "").not.toMatch(PAYWALL); + }); +} diff --git a/frontend/src/types.ts b/frontend/src/types.ts new file mode 100644 index 0000000..449edb9 --- /dev/null +++ b/frontend/src/types.ts @@ -0,0 +1,66 @@ +// Console domain types. +// +// There is deliberately no cost field anywhere in here. Free records what +// happened on a call; what it cost is ShipVoice Pro. A nullable cost_usd in a +// public schema is an invitation to fill it in with a guess. + +export type CallChannel = "web" | "sip"; +export type CallStatus = "active" | "completed" | "failed"; + +export interface CallRead { + id: number; + room_name: string; + caller: string | null; + channel: CallChannel; + agent_name: string | null; + business_name: string | null; + status: CallStatus; + started_at: string; + ended_at: string | null; + duration_seconds: number | null; + turn_count: number; +} + +export interface CallListResponse { + calls: CallRead[]; + total: number; +} + +export interface TurnRead { + id: number; + role: "user" | "agent"; + text: string; + spoken_at: string; +} + +export interface CallDetailResponse { + call: CallRead; + transcript: TurnRead[]; +} + +export interface CallSummaryResponse { + total_calls: number; + total_minutes: number; + total_turns: number; +} + +export interface AgentSummary { + slug: string; + agent_name: string; + business_name: string | null; + active: boolean; + prompt_path: string; + stt: string | null; + llm: string | null; + tts: string | null; +} + +export interface AgentListResponse { + agents: AgentSummary[]; +} + +/** Matches the backend's standardized LiveKit token shape exactly. */ +export interface RoomTokenResponse { + server_url: string; + participant_token: string; +} From c602cbc0c4405d7d71c7ec04dedc46ee56c84743 Mon Sep 17 00:00:00 2001 From: Mahimai Raja J Date: Sat, 8 Aug 2026 13:32:01 -0400 Subject: [PATCH 07/28] feat(frontend): no sign-in, one Upgrade button, the real mark Four changes the founder asked for. Sign-in is gone. The console opens straight onto the agent, which is the right trade on the machine you are running it on. The consequence is written down in Settings and it is real: everything this console reads, an anonymous visitor reads too, including transcripts once the call log is wired. The backend keeps its JWT user slice and its superuser flag, so gating it later is adding a dependency to the routes, not building auth. GET /agents opens with it, since it only reports the agent name and the provider names this repo already publishes in its source. The four ShipVoice Pro rail items are grey and inert. There is nothing behind them here, so a link would only lead somewhere whose purpose is to sell. No promotion anywhere else. The locked pages, the in-page locks over the money strip, the receipt, the cost columns and the AI engineer are all gone. What stayed is every factual line they were wrapped around, including the sentence that matters most: this starter has no compliance gate, it will dial whoever you point it at, and you are the caller of record. One Upgrade button in the topbar instead, on every page, booking a call. Also swaps the placeholder rail mark and the purple bolt favicon for the real ShipVoice logo, and carves the name and the mark out of the MIT grant. The code is theirs; the brand is not. --- LICENSE | 4 + backend/src/api/endpoints/agents.py | 12 +-- backend/tests/unit/test_agents_endpoint.py | 16 ++-- frontend/public/favicon.svg | 10 ++- frontend/public/logo-boat.svg | 1 + frontend/src/App.tsx | 37 +-------- frontend/src/api.ts | 74 ++++------------- frontend/src/components/AppShell.tsx | 11 ++- frontend/src/components/LoginForm.tsx | 56 ------------- frontend/src/components/Rail.tsx | 59 +++++++------ frontend/src/components/console.test.tsx | 65 +++++++++++++++ frontend/src/components/ds.tsx | 36 -------- frontend/src/console.css | 96 ++++++---------------- frontend/src/pages/AgentDetail.tsx | 1 - frontend/src/pages/Agents.tsx | 17 ++-- frontend/src/pages/CallDetail.tsx | 25 +----- frontend/src/pages/CallLogs.tsx | 41 ++------- frontend/src/pages/Overview.tsx | 39 +-------- frontend/src/pages/Settings.tsx | 50 +++++------ frontend/src/pages/locked/Campaigns.tsx | 15 ---- frontend/src/pages/locked/Channels.tsx | 15 ---- frontend/src/pages/locked/Customers.tsx | 14 ---- frontend/src/pages/locked/Evaluations.tsx | 15 ---- frontend/src/pages/locked/LockedPage.tsx | 48 ----------- frontend/src/pages/locked/locked.test.tsx | 56 ------------- 25 files changed, 219 insertions(+), 594 deletions(-) create mode 100644 frontend/public/logo-boat.svg delete mode 100644 frontend/src/components/LoginForm.tsx create mode 100644 frontend/src/components/console.test.tsx delete mode 100644 frontend/src/pages/locked/Campaigns.tsx delete mode 100644 frontend/src/pages/locked/Channels.tsx delete mode 100644 frontend/src/pages/locked/Customers.tsx delete mode 100644 frontend/src/pages/locked/Evaluations.tsx delete mode 100644 frontend/src/pages/locked/LockedPage.tsx delete mode 100644 frontend/src/pages/locked/locked.test.tsx diff --git a/LICENSE b/LICENSE index 7d2d518..5f1dd05 100644 --- a/LICENSE +++ b/LICENSE @@ -30,3 +30,7 @@ notice above. They are listed in THIRD_PARTY_NOTICES.md. Images under assets/ that depict ShipVoice Pro are included for comparison and are not covered by the grant above. They remain the property of the author. + +The same applies to the ShipVoice name and the mark in frontend/public. The +code is yours to use under the terms above; the brand is not. Ship what you +build under your own name. diff --git a/backend/src/api/endpoints/agents.py b/backend/src/api/endpoints/agents.py index cb89dfc..db64974 100644 --- a/backend/src/api/endpoints/agents.py +++ b/backend/src/api/endpoints/agents.py @@ -1,7 +1,6 @@ from dependency_injector.wiring import Provide, inject from fastapi import APIRouter, Depends -from src.api.deps import AdminUser from src.core.config import Config from src.core.container import Container from src.schemas.agents_schemas import AgentListResponse, AgentSummary @@ -27,14 +26,17 @@ @router.get("", response_model=AgentListResponse) @inject async def list_agents( - _actor: AdminUser, config: Config = Depends(Provide[Container.config]), ) -> AgentListResponse: """List the agents this deployment runs. - Free runs exactly one worker serving one agent, selected by AGENT_NAME, - so this returns a single row. Running several agents from one account is - ShipVoice Pro. + One worker serves one agent, selected by AGENT_NAME, so this returns a + single row. + + Unauthenticated, because the console has no sign-in. What it exposes is the + agent's name and the provider names already published in this repo's source + and README, so there is nothing here a reader of the repo does not have. + Adding "_actor: AdminUser" from src.api.deps gates it if you want it gated. """ return AgentListResponse( agents=[ diff --git a/backend/tests/unit/test_agents_endpoint.py b/backend/tests/unit/test_agents_endpoint.py index 36fb8ef..41ffae1 100644 --- a/backend/tests/unit/test_agents_endpoint.py +++ b/backend/tests/unit/test_agents_endpoint.py @@ -13,7 +13,6 @@ from fastapi import FastAPI from httpx import ASGITransport, AsyncClient -from src.api.deps import get_current_admin, get_current_user_record from src.api.endpoints.agents import ( DECLARED_IN, DECLARED_LLM, @@ -31,7 +30,7 @@ REPO_ROOT = pathlib.Path(__file__).resolve().parents[3] -def _build_app(actor: User): +def _build_app(_actor: User): cfg = Config( ENV="dev", _env_file=None, @@ -45,9 +44,6 @@ def _build_app(actor: User): app = FastAPI() app.include_router(agents_router, prefix="/api/v1") # Bypass the JWT decode; this suite is about the endpoint and its gate. - app.dependency_overrides[get_current_user_record] = lambda: actor - if actor.is_superuser: - app.dependency_overrides[get_current_admin] = lambda: actor return app, container @@ -77,14 +73,20 @@ async def test_lists_the_single_configured_agent(): @pytest.mark.asyncio -async def test_a_non_admin_cannot_list_agents(): +async def test_listing_agents_needs_no_account(): + """The console has no sign-in, so this route must stay open. + + It exposes the agent name and the provider names, both already published in + this repo's source and README. If you gate it, gate the console too or the + Agents page goes blank with a 403 nobody can act on. + """ plain = User( id=2, email="s@e.co", hashed_password="x", is_active=True, is_superuser=False ) app, container = _build_app(plain) try: resp = await _get_agents(app) - assert resp.status_code == 403 + assert resp.status_code == 200 finally: container.unwire() diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg index 6893eb1..cededd6 100644 --- a/frontend/public/favicon.svg +++ b/frontend/public/favicon.svg @@ -1 +1,9 @@ - \ No newline at end of file + + + + + + + + + diff --git a/frontend/public/logo-boat.svg b/frontend/public/logo-boat.svg new file mode 100644 index 0000000..3279969 --- /dev/null +++ b/frontend/public/logo-boat.svg @@ -0,0 +1 @@ + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e7a5f2f..046dc05 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,39 +1,16 @@ -import { useCallback, useEffect, useState } from "react"; import { Navigate, Route, Routes } from "react-router"; -import { SESSION_EXPIRED_EVENT, clearToken, getToken } from "./api"; import { AppShell } from "./components/AppShell"; -import { LoginForm } from "./components/LoginForm"; import { Overview } from "./pages/Overview"; import { CallLogs } from "./pages/CallLogs"; import { CallDetail } from "./pages/CallDetail"; import { Agents } from "./pages/Agents"; import { AgentDetail } from "./pages/AgentDetail"; import { Settings } from "./pages/Settings"; -import { Campaigns } from "./pages/locked/Campaigns"; -import { Channels } from "./pages/locked/Channels"; -import { Customers } from "./pages/locked/Customers"; -import { Evaluations } from "./pages/locked/Evaluations"; +// No sign-in. The console is a local tool for whoever owns the deployment, so +// it opens straight onto the agent. See the deploy note in the README before +// putting it on a public address. export default function App() { - const [loggedIn, setLoggedIn] = useState(() => getToken() != null); - - // A 401 anywhere means the token lapsed. api.ts clears it and fires this - // event so every page does not have to handle expiry on its own. - useEffect(() => { - const onExpired = (): void => setLoggedIn(false); - window.addEventListener(SESSION_EXPIRED_EVENT, onExpired); - return () => window.removeEventListener(SESSION_EXPIRED_EVENT, onExpired); - }, []); - - const handleLogout = useCallback(() => { - clearToken(); - setLoggedIn(false); - }, []); - - if (!loggedIn) { - return setLoggedIn(true)} />; - } - return ( }> @@ -42,13 +19,7 @@ export default function App() { } /> } /> } /> - } /> - {/* Designed in ShipVoice Pro, not backed there yet. These routes open - and say so; they are previews, not a paywall. */} - } /> - } /> - } /> - } /> + } /> } /> diff --git a/frontend/src/api.ts b/frontend/src/api.ts index b09c76a..7b5e564 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -1,4 +1,4 @@ -// The only module that knows the base URL, token storage, and auth headers. +// The only module that knows the backend's base URL. import type { AgentListResponse, CallDetailResponse, @@ -9,34 +9,19 @@ import type { export const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000"; -const TOKEN_KEY = "shipvoice_token"; - -export function getToken(): string | null { - return localStorage.getItem(TOKEN_KEY); -} - -export function setToken(token: string): void { - localStorage.setItem(TOKEN_KEY, token); -} - -export function clearToken(): void { - localStorage.removeItem(TOKEN_KEY); -} - -export function authHeaders(): Record { - const t = getToken(); - return t ? { Authorization: `Bearer ${t}` } : {}; -} - -/** Broadcast when a request comes back 401, so App can drop to the login form. */ -export const SESSION_EXPIRED_EVENT = "shipvoice:session-expired"; +// This console has no sign-in. It is a local tool for the person who owns the +// deployment, and a login screen between someone and their own agent is +// friction that buys nothing on localhost. +// +// That is a deliberate trade, and it has a consequence worth knowing before +// you put this on the public internet: anything the console can read, an +// anonymous visitor can read too. See the deploy section of the README. /** - * A failed request, with the three states the UI must never conflate. + * A failed request, with the states the UI must never conflate. * - * Rendering "API unreachable" for a lapsed token sends people to check Docker - * when the fix is to sign in again, and rendering it for a 403 hides the fact - * that the account simply is not an admin. + * Rendering "API unreachable" for a 404 sends people to check Docker when the + * real answer is that the route does not exist in this checkout yet. */ export class ApiError extends Error { readonly status: number; @@ -47,12 +32,9 @@ export class ApiError extends Error { this.status = status; } - get isExpiredSession(): boolean { - return this.status === 401; - } - + /** Only reachable if you have gated the backend yourself. */ get isForbidden(): boolean { - return this.status === 403; + return this.status === 401 || this.status === 403; } get isUnreachable(): boolean { @@ -67,17 +49,13 @@ export class ApiError extends Error { async function guard(res: Response, what: string): Promise { if (res.ok) return; - if (res.status === 401) { - clearToken(); - window.dispatchEvent(new Event(SESSION_EXPIRED_EVENT)); - } throw new ApiError(res.status, `${what} failed (${res.status})`); } async function get(path: string, what: string): Promise { let res: Response; try { - res = await fetch(`${API_BASE}${path}`, { headers: authHeaders() }); + res = await fetch(`${API_BASE}${path}`); } catch { throw new ApiError(0, `${what} could not reach the backend`); } @@ -85,23 +63,6 @@ async function get(path: string, what: string): Promise { return (await res.json()) as T; } -export async function login(email: string, password: string): Promise { - let res: Response; - try { - // Free's route is /users/login. Pro's is /auth/login; a verbatim port 404s. - res = await fetch(`${API_BASE}/api/v1/users/login`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ email, password }), - }); - } catch { - throw new ApiError(0, "Could not reach the backend"); - } - await guard(res, "Sign in"); - const data = (await res.json()) as { access_token: string }; - setToken(data.access_token); -} - export interface CallListParams { limit?: number; offset?: number; @@ -131,10 +92,7 @@ export async function getCall(id: number | string): Promise export async function deleteCall(id: number | string): Promise { let res: Response; try { - res = await fetch(`${API_BASE}/api/v1/calls/${id}`, { - method: "DELETE", - headers: authHeaders(), - }); + res = await fetch(`${API_BASE}/api/v1/calls/${id}`, { method: "DELETE" }); } catch { throw new ApiError(0, "Could not reach the backend"); } @@ -160,7 +118,7 @@ export async function getTestCallToken(agentName: string): Promise {actions} + + Upgrade + ); } diff --git a/frontend/src/components/LoginForm.tsx b/frontend/src/components/LoginForm.tsx deleted file mode 100644 index d810b2e..0000000 --- a/frontend/src/components/LoginForm.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { useState, type FormEvent } from "react"; -import { ApiError, login } from "../api"; - -export function LoginForm({ onLoggedIn }: { onLoggedIn: () => void }) { - const [email, setEmail] = useState(""); - const [password, setPassword] = useState(""); - const [error, setError] = useState(null); - - async function handleSubmit(event: FormEvent): Promise { - event.preventDefault(); - setError(null); - try { - await login(email, password); - onLoggedIn(); - } catch (e) { - // Distinguish a dead backend from a bad password. Telling someone their - // password is wrong when Docker is not running sends them to the wrong fix. - if (e instanceof ApiError && e.isUnreachable) { - setError("Could not reach the backend. Is it running on " + location.hostname + ":8000?"); - } else { - setError("Sign in failed. Check your email and password."); - } - } - } - - return ( -
-
-

ShipVoice

-

- Sign in with the account /setup created. -

- setEmail(event.target.value)} - placeholder="email" - /> - setPassword(event.target.value)} - placeholder="password" - /> - - {error && ( -

- {error} -

- )} -
-
- ); -} diff --git a/frontend/src/components/Rail.tsx b/frontend/src/components/Rail.tsx index e6714ba..b9a4852 100644 --- a/frontend/src/components/Rail.tsx +++ b/frontend/src/components/Rail.tsx @@ -4,11 +4,11 @@ import { NavLink } from "react-router"; // what is dialling, Build what dials next. Settings is not a nav item; it lives // in the footer under the deployment identity. // -// Four items are locked. They are ShipVoice Pro surfaces that Pro has designed -// and not yet backed, so they are previews, not a paywall: the route still -// opens and says plainly what is and is not there. The features actually worth -// paying for are locked in place on the live pages instead, via LockedBlock. -type Item = { label: string; to: string; count?: number | null; locked?: boolean }; +// Four items are ShipVoice Pro surfaces. They are shown so the shape of the +// full product is visible, and they are inert: grey, no link, no route. There +// is nothing behind them in this repo, so making them clickable would only +// lead somewhere that exists to sell you something. +type Item = { label: string; to?: string; count?: number | null; pro?: boolean }; type Group = { title: string; items: Item[] }; export function Rail({ @@ -29,16 +29,16 @@ export function Rail({ { title: "Run", items: [ - { label: "Campaigns", to: "/campaigns", locked: true }, - { label: "Channels", to: "/channels", locked: true }, - { label: "Customers", to: "/customers", locked: true }, + { label: "Campaigns", pro: true }, + { label: "Channels", pro: true }, + { label: "Customers", pro: true }, ], }, { title: "Build", items: [ { label: "Agents", to: "/agents", count: counts.agents ?? null }, - { label: "Evaluations", to: "/evaluations", locked: true }, + { label: "Evaluations", pro: true }, ], }, ]; @@ -46,10 +46,7 @@ export function Rail({ return (