BawtHub
⌕ Search ⌘K Source ↗ Open app →
BawtHub · frontend

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.

Stack: Next.js 16.1.6 · React 19.2 · Tailwind 4.2 · TypeScript 5 State: Zustand 5 · TanStack Query 5 · Prisma 7 Dev: webpack (not Turbopack)

01 Two route groups.

App Router gives you route groups (name) that affect layout inheritance without changing URLs. BawtHub uses two:

GroupURL prefixLayoutPages
(app)/(dashboard)/, /tools/*, /agents/*, /docker, /studio/*, /unraidSide nav, top sub-nav strip, glass panelsHome tiles, tools admin, agent dashboard, container monitor, studio
(app)/(fullscreen)/chat, /voice, /avatarNo chrome, edge-to-edgeThe 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/ · top-level shape
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:

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:

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.

The chat store has a sort comparator that costs about a paragraph to explain.

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:

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:

PrefixPurpose
/api/chat/botsBot registry — list, get, update personalities
/api/chat/modelsModel registry + current-model lookup
/api/chat/historyMessage history with pagination, search, summaries
/api/chat/memoryMemory CRUD, consolidate, forget, search, regenerate embeddings, stats, preview, restore
/api/chat/tool-callsTool execution logs
/api/chat/turn-logsPer-turn analytics
/api/chat/tasksAgent 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:

PrefixStoragePurpose
/api/preferencesPrisma · UserPreferenceNav favorites + per-user key/value settings
/api/bot-colors/[richName]Prisma · BotColorMappingPer-bot color schemes for the UI
/api/avatar/*Prisma · AvatarSettings, BoneMapping3D model registry, persisted material/bone overrides
/api/camera-presetsPrisma · AvatarSettingsSaved camera positions for the avatar viewer
/api/unraid/*Unraid GraphQL + Prisma UnraidContainerGroupLive container state + persisted group ordering
/api/docker/containersLocal Docker socketRead-only container listing for the docker dashboard
/api/notificationsPrisma · BotReplyNotificationCross-bot reply nudges
/api/agents/*Prisma · AgentProject, AgentTask, AgentStep, AgentActivityThe full task system — projects, tasks, steps, activity, cron
/api/uploadsDisk + Prisma · UploadImage attachment storage for chat
/api/clogBackend log forwarderBrowser 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:

Each browser window gets its own SSE consumer id.

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 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:

UserPreference
User-scoped key/value settings — nav favorites, default users, last-selected anything.
BotColorMapping
Per-bot color schemes overriding the auto-generated palette.
UnraidContainer · UnraidContainerGroup
Drag-and-drop container groups; the live state is fetched fresh from Unraid's GraphQL each load, but the grouping/order is persisted here.
AvatarSettings · AvatarSettingsPreset · BoneMapping
Per-model overrides — mesh visibility, morph targets, material tints, bone retarget maps, camera presets.
AgentProject · AgentTask · AgentStep · AgentActivity
The full task system schema. Projects own tasks, tasks own steps, activity is a per-task audit log. The agent system lives in the BawtHub DB, not in llm-bawt.
BotReplyNotification
Cross-bot reply nudges: when bot A wants to ping you about something it noticed for bot B.
Upload
Image attachment metadata — disk path, mime, sha, owning user. The files themselves live in /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.tsx
Voice orchestrator. ~2,200 lines. WebSocket lifecycle, audio context setup, transcript state, health polling, error surfaces, voice-call wake-lock, and the bridge between audio output amplitude and the orb/avatar.
frontend/src/app/chat/ChatUI.tsx
The chat surface. ~3,500 lines. Message rendering, streaming reconciliation, agent turn tracking, inline tool activity, the unified composer popup (compact + desktop), markdown + code highlighting, image attachments, summaries toggle.
frontend/src/app/chat/useChatStore.ts
Per-bot chat store. The most consequential Zustand store in the app — messages, activity, turn-to-message routing, partial-response cache, SSE status.
frontend/src/app/chat/useUnifiedEventStream.ts
SSE consumer. One subscription per bot, with window-scoped consumer ids to avoid Redis consumer-group races. Handles tool_start, tool_end, turn_complete.
frontend/src/app/useAppStore.ts
Global selections. Bot, user, voice id, voice provider, avatar model — persisted to localStorage.
frontend/src/app/api/chat/proxy/[...path]/route.ts
The catch-all. Forwards arbitrary /v1/* requests to llm-bawt — used by tool admin pages and the raw API explorer.
frontend/prisma/schema.prisma
The UI database. All BawtHub-owned persistent state — agent system, avatar overrides, Unraid groups, user prefs.
frontend/Dockerfile · frontend/hot-reloading.Dockerfile
Two builds. Production Standalone build for the optional snapshot container; hot-reloading dev build for the live HMR container that serves every public + LAN hostname.
Validated against main on 2026-05-13 Source: bawthub repo (private)