@adaptcom/ui
v0.4.4
Published
Static page assets for Adapt agent services.
Downloads
6,515
Keywords
Readme
@adaptcom/ui
The static page an agent service serves. Requires Node 24 or later.
pnpm add @adaptcom/api @adaptcom/uiimport { defineAgent } from "@adaptcom/core";
import { agentApi } from "@adaptcom/api";
import { agentUi } from "@adaptcom/ui";
export default defineAgent({
harness,
channels: [agentApi({ auth }), agentUi()],
});agentUi({ apiPath = "/adapt/v1" }) returns a fetch channel named ui at
/. It answers GET and HEAD from in-memory strings, falling back to
index.html so client-side routing works. Other methods get a 404, so a
misdelivered webhook fails cleanly. The page is a chat client for the agent
API, and the service routes the API's path to agentApi() ahead of this
fallback. Startup fails unless exactly one channel named api is in the same
channels list, mounted at apiPath; pass the same path to both when you
change it. The served index.html carries
<meta name="adapt-api-path" content="...">, which the page reads to find the
API. The page adds no authentication of its own. When an API call
returns 401 login_required, the page redirects to auth/login with the
current hash route as returnTo, so sign-in lands back on the same chat. The
sidebar footer shows the signed-in user's name, email, or ID (hidden for the
anonymous local user) and a Sign out button when the provider supports it.
When POST auth/logout answers { redirectTo }, sign-out follows it so the
provider ends its session too. After sign-out the page shows a Sign in button
instead of redirecting, since the provider's session could sign the user
straight back in. A failed logout keeps the user signed in and shows an error.
See the agent API guide,
the UI example,
and the OIDC example.
The page
A sessions sidebar from GET /sessions, and a chat pane that polls
GET /sessions/:id/events with the returned cursor: immediately while
hasMore, every 500ms during a turn, every 1.5s when idle. Sending posts the
message and resolves when the turn ends; Stop cancels it. There is no token
streaming, so a reply appears when the harness commits it.
Routes use the hash (#/, #/chat/<id>), so the pathname stays at the mount
path and the API base is location.pathname plus the adapt-api-path meta
tag (/adapt/v1 under the Vite dev server, which has no tag). That keeps the
page working under an ingress prefix such as /ingress/<id>/3000.
The stack: Tailwind v4 tokens, shadcn components on Base UI, TanStack Router
and Query, and Streamdown for markdown. Code blocks use shiki/core
(createHighlighterCore with the JavaScript regex engine) and a fixed set of
statically imported grammars in components/chat/syntax-highlight.tsx, so the
bundle carries only those languages.
ai is used only for its UIMessage types; the page does not use useChat.
Develop
pnpm adapt serve examples/with-ui --port 3000 # from the repo root
pnpm --filter @adaptcom/ui devVite serves the page with hot reload and proxies /adapt to port 3000.
Layout
Two worlds live here, split by directory:
index.html Vite entry
src/ React app, built by Vite, never imported at runtime
tsconfig.json browser config
main.tsx providers and router mount
router.tsx hash routes: draft chat and /chat/$chatId
components/ chat, sidebar, ai-elements, and shadcn ui primitives
hooks/ use-agent-session: polling, send, stop
lib/ api client, toUIMessages, theme and utilities
queries/ TanStack Query options for /agent, /me, and /sessions
public/ copied verbatim into dist/app
build/
index.ts the package entry, compiled by tsc
assets.ts generated, gitignored
dist/
app/ Vite output
index.js what @adaptcom/ui resolves toNothing in src is a dependency of build. The React app reaches build only
as generated strings, which is why the browser code never appears in the
service's import graph.
The split is why there are three tsconfigs. The root tsconfig.json and
tsconfig.build.json are the same check/emit pair core and cli use, scoped
to build. src/tsconfig.json covers the React app with lib: DOM and
jsx: react-jsx, options that contradict the Node ones. It sits inside src
rather than at the package root so an editor opening a .tsx file finds it by
the usual upward search; at the root it would be shadowed by the Node config.
pnpm typecheck runs both sides.
Build
pnpm build # vite build && node scripts/inline.mjs && tsc -p tsconfig.build.jsonscripts/inline.mjs is the interesting step: it walks dist/app and writes
every file into build/assets.ts as a string literal. That turns the compiled
page into an ordinary JS module, so the esbuild pass in @adaptcom/cli sweeps
it into the single agent.mjs with no bundler configuration. An agent that
does not use agentUi() never imports the module and never pays for it.
On the way it folds the built script, stylesheet, and icon into index.html,
so the served page links nothing. That is what lets a deployment ingress serve
it from any path: an absolute /assets/... escapes the prefix, and a relative
one resolves a segment too high whenever the URL lacks a trailing slash, as
/ingress/<id>/3000 does. A page with no URLs of its own has neither problem.
The build fails if a tag escapes the fold and leaves an asset behind.
prepare builds workspace installs; prepack rebuilds before publishing so
the npm package includes the compiled page and TypeScript declarations.
Assets must be text
scripts/inline.mjs throws on any extension outside its mime map rather than
guessing. Assets are inlined as UTF-8, so a binary file would ship corrupt, and
a loud build failure is better than a broken page. vite.config.ts sets
assetsInlineLimit: Infinity, cssCodeSplit: false, and
build.rolldownOptions.output.codeSplitting: false so the fold above has one
script and one stylesheet to deal with.
Fonts end up as data URLs in the stylesheet, and lazy imports are bundled
rather than split into chunks.
public/ copies files verbatim into dist/app, so anything added there needs
a matching entry in the mime map. favicon.svg is the only file that lives
there today.
