A Next.js app pretending to be three apps.
The BawtHub frontend is a single Next.js 16 application with two route-group layouts (full-chrome dashboard vs. edge-to-edge surfaces), a per-bot Zustand chat store, a windowed SSE event bus, and roughly fifty API routes that mostly forward to llm-bawt. The interesting parts aren't the components — they're the way state is split between the URL, localStorage, Postgres-via-Prisma, and a Redis-backed event stream upstream.
01 Two route groups.
App Router gives you route groups (name) that affect layout inheritance without changing URLs. BawtHub uses two:
| Group | URL prefix | Layout | Pages |
|---|---|---|---|
(app)/(dashboard) | /, /tools/*, /agents/*, /docker, /studio/*, /unraid | Side nav, top sub-nav strip, glass panels | Home tiles, tools admin, agent dashboard, container monitor, studio |
(app)/(fullscreen) | /chat, /voice, /avatar | No chrome, edge-to-edge | The three "modal" surfaces — chat, voice call, avatar viewer |
The (app) wrapper holds the floating bot list, the bot dropdown, the feedback button, and the global event listeners. Inside, (dashboard) adds the sidebar + sub-nav while (fullscreen) strips them away. URLs are the same either way: /chat, /voice, etc.
02 The component tree, flat by design.
BawtHub doesn't have a deep components/ tree. The top of src/app/ is a flat surface of orchestrator components — each one paired with whatever hooks and stores it needs, in the same directory.
src/app/ ├── BawtHub.tsx // voice orchestrator: WS, audio, chat state (2,200 lines) ├── AnimatedOrb.tsx // audio-reactive canvas viz ├── AvatarViewer.tsx // R3F canvas wrapper for /avatar and inline embeds ├── FloatingTranscript.tsx // word-by-word transcript overlay ├── AppEventStream.tsx // app-level SSE listener ├── AppMainNav.tsx // sidebar nav + favorites ├── AppPreferencesSync.tsx // reconciles server prefs ↔ Zustand ├── useAppStore.ts // global selections (bot, user, voice, avatar) ├── useAvatarStore.ts // avatar models + pending animations ├── useAudioProcessor.ts // mic capture + Opus encode ├── useRealtimeAudioOutput.ts // Opus decode + AudioWorklet playback ├── useSpeechRecognition.ts // browser STT helper for "type to speak" ├── useWakeLock.ts // screen-on during voice calls ├── chat/ // text chat surface ├── avatar/ // R3F model loaders, lip sync, animations ├── tools/ // shared tool helpers (api.ts, sub-nav) ├── unraid/ // dashboard component ├── agents/ // task system server actions ├── (app)/(dashboard)/... // dashboard pages ├── (app)/(fullscreen)/... // chat, voice, avatar pages └── api/ // ~50 Next.js API routes
This isn't an accident. Co-locating useChatStore.ts next to ChatUI.tsx means a refactor of chat state doesn't touch a global store/ directory. The trade-off is that ChatUI.tsx is ~3,500 lines — handling message rendering, streaming, agent turn tracking, tool activity display, and the unified composer popup all in one place. The team has consciously chosen "one big file, easy to navigate" over a fan of forty smaller components.
03 Zustand: three stores.
BawtHub has three Zustand stores, each with a tight scope:
useAppStore
Global selections, persisted to localStorage under bawthub-app-settings:
selectedBot— single source of truth for which bot is active across every pageselectedUser— the user-id namespace (memory + history are user-scoped)selectedVoiceId+selectedVoiceProvider— TTS routing, persisted as a pair so the UI knows which provider owns the voice without a backend roundtripselectedAvatarModel— last avatar path (default/models/Rebecca.vrm)
AppPreferencesSync reconciles these with server-side preferences (Prisma's UserPreference table) on first load — treating server as authoritative but never blocking initial render on the async fetch.
useChatStore
Per-bot chat state, keyed by bot id:
messages— sorted message array (timestamp primary, role tiebreaker, id deterministic-final)activityByMessageId— tool calls bucketed under the user message that triggered the turnturnToMessageId— turn-id → user-message-id mapping so SSE tool events can route to the right bubble even when the trigger ID gets reconciled from optimistic to UUID mid-turnpartialResponseByUserMsgId— partial assistant text from turn-log for the resume case (refresh during a stream)turnCompletions— bounded ledger ofturn_completeevents with token usagesseStatus— connected / reconnecting / disconnected
useAvatarStore
Avatar registry + pending-animation queue. Models and animation clips come from /api/avatar/*; the LLM can also queue an animation by name (pendingAnimation) which the AvatarViewer consumes and clears.
Optimistic local IDs (local-user-*) get assigned timestamps from the client clock, which can race the server's timestamp by a few ms. The sort uses timestamp primary → role priority (user before assistant) → id lex order → original index. This stops the optimistic assistant placeholder from appearing above the user message that triggered it.
04 Server state via TanStack Query.
Anything that's actually owned by llm-bawt — bot lists, models, turn logs, memory dashboards, tool-call history — goes through TanStack Query with stale-while-revalidate semantics. The patterns are conventional:
- Query keys include the bot id and user id so cache entries don't cross-pollute when you switch personalities.
- Mutations invalidate by prefix — saving a memory invalidates
['memory', botId], not the world. - Long polls are intentionally absent: streaming data uses SSE (below), so React Query never has to do interval polling for live state.
05 The proxy floor.
Almost every API route under src/app/api/chat/* is a thin forwarder to llm-bawt at BAWTHUB_LLM_URL (defaults to http://host.docker.internal:8642). The split is by concern, not by endpoint count:
| Prefix | Purpose |
|---|---|
/api/chat/bots | Bot registry — list, get, update personalities |
/api/chat/models | Model registry + current-model lookup |
/api/chat/history | Message history with pagination, search, summaries |
/api/chat/memory | Memory CRUD, consolidate, forget, search, regenerate embeddings, stats, preview, restore |
/api/chat/tool-calls | Tool execution logs |
/api/chat/turn-logs | Per-turn analytics |
/api/chat/tasks | Agent task ops (creation, dispatch, status — bridged to llm-bawt's task subsystem) |
/api/chat/proxy/[...path] | Catch-all for long-tail endpoints not worth a dedicated route |
/api/llm/* | LLM admin — models, settings, profiles, jobs, status |
Routes that don't proxy to llm-bawt own their own state in BawtHub's Prisma database:
| Prefix | Storage | Purpose |
|---|---|---|
/api/preferences | Prisma · UserPreference | Nav favorites + per-user key/value settings |
/api/bot-colors/[richName] | Prisma · BotColorMapping | Per-bot color schemes for the UI |
/api/avatar/* | Prisma · AvatarSettings, BoneMapping | 3D model registry, persisted material/bone overrides |
/api/camera-presets | Prisma · AvatarSettings | Saved camera positions for the avatar viewer |
/api/unraid/* | Unraid GraphQL + Prisma UnraidContainerGroup | Live container state + persisted group ordering |
/api/docker/containers | Local Docker socket | Read-only container listing for the docker dashboard |
/api/notifications | Prisma · BotReplyNotification | Cross-bot reply nudges |
/api/agents/* | Prisma · AgentProject, AgentTask, AgentStep, AgentActivity | The full task system — projects, tasks, steps, activity, cron |
/api/uploads | Disk + Prisma · Upload | Image attachment storage for chat |
/api/clog | Backend log forwarder | Browser console events shipped server-side for debugging |
06 Streaming: SSE for events, HTTP for chunks.
BawtHub uses two different streaming patterns depending on what's flowing:
Chat completions over HTTP streaming
When the user sends a chat message, the frontend posts to /api/chat (which forwards to llm-bawt's /v1/chat/completions) and reads the response body as a stream. The new chunked text is appended to an "in-flight" message bubble. A shared ChatStreamContext coordinates the active stream across the chat UI so multiple components can render from the same source.
Tool events + turn completion over SSE
useUnifiedEventStream opens a single Server-Sent Events subscription per bot to consume the upstream's event bus:
tool_start— backend (claude-code, codex, openclaw, …) reports a tool invocation with argumentstool_end— the result lands, with truncated stdout/stderrturn_complete— the turn finishes with status, optional animation cue, and token-usage metrics
llm-bawt's event bus is a Redis stream with consumer groups. If two open windows shared a consumer id, they'd race to ACK events and one window would never see tool starts. useUnifiedEventStream generates a window-scoped window-{uuid} id stashed on the global object — deliberately not persisted, so a refresh gets a fresh group and a duplicate event replay during the rejoin grace period.
The trigger-message-id reconciliation problem
The biggest piece of complexity in useChatStore is mapping incoming tool events back to the correct user-message bubble. The user message starts with an optimistic local-user-* id; when the server assigns a real UUID, the store re-keys activity, stream state, and turn-id mappings — but the server keeps emitting the original trigger_message_id in its events because that's what's persisted in turn_log. The store resolves this by checking four candidates per event (trigger id, turn-mapping, active stream, last user msg id) and preferring whichever one actually exists in the messages array.
07 The voice page and its WebSocket.
The /voice page mounts BawtHub.tsx — a ~2,200-line orchestrator that owns:
- The microphone WebSocket via
react-use-websocket 4.13, talking to the Python backend at/v1/ws - An Opus encoder running in a worker (
/public/encoderWorker.min.js) for the upstream audio - An Opus decoder worker plus an
AudioWorkletprocessor (/public/audio-output-processor.js) for the downstream - An
AnalyserNodetap on the output, exposed toAnimatedOrbfor the pulsing visualization and toAvatarViewerfor lip sync
The wire protocol mirrors the OpenAI Realtime API — input_audio_buffer.append, session.update, response.audio.delta, response.text.delta, conversation.item.input_audio_transcription.delta. This is intentional: it means a future swap to a different realtime backend doesn't need a different schema.
08 The Prisma schema.
BawtHub's Postgres holds UI-flavored state that doesn't belong upstream. The schema (loaded via frontend/prisma/schema.prisma, pushed with make db-push-ui) defines:
UserPreferenceBotColorMappingUnraidContainer · UnraidContainerGroupAvatarSettings · AvatarSettingsPreset · BoneMappingAgentProject · AgentTask · AgentStep · AgentActivityBotReplyNotificationUpload/app/storage, bind-mounted from Unraid.Connection strings come from DATABASE_URL. Prisma 7's Postgres adapter handles pool reuse; the schema is intentionally PostgreSQL-only (relies on @id @default(cuid()), JSON fields, and Postgres-specific casts).
09 Styling: Tailwind 4 + a glass dialect.
Tailwind 4.2 with CSS variables for theming. The look is a dark glass-morphism with cyan/violet/pink accents — .glass-panel is the workhorse: backdrop-blur, 1px border, subtle inner glow. Custom animations (slide-up, fade-in, glow-pulse, shimmer) live in globals.css. The font is Satoshi via next/font/local; Inter is the fallback for system stacks.
10 The dev loop.
The frontend container bind-mounts ./frontend from the host and runs next dev --webpack. CHOKIDAR_USEPOLLING and WATCHPACK_POLLING are on so file events propagate through the bind mount even on filesystems where inotify doesn't traverse cleanly. Hot reload is the deploy path — there is no separate make rebuild-prod step in normal release flow. The frontend-prod snapshot profile exists only when you need a stable baked build to compare against.
11 Key files.
frontend/src/app/BawtHub.tsxfrontend/src/app/chat/ChatUI.tsxfrontend/src/app/chat/useChatStore.tsfrontend/src/app/chat/useUnifiedEventStream.tsfrontend/src/app/useAppStore.tsfrontend/src/app/api/chat/proxy/[...path]/route.ts/v1/* requests to llm-bawt — used by tool admin pages and the raw API explorer.frontend/prisma/schema.prismafrontend/Dockerfile · frontend/hot-reloading.Dockerfilemain on 2026-05-13
Source: bawthub repo (private)