14 KiB
Chat Client
Package: @nexusai/chat-client
Location: packages/chat-client
Deployed on: Mini PC 2 (192.168.0.205)
URL: https://nexus.jellystorm.com (behind Authelia SSO)
Purpose
Browser-based chat interface for NexusAI. Communicates exclusively with the orchestration service — no direct access to memory, embedding, or inference services. Served as static files by Caddy on Mini PC 2.
Dependencies
react+react-dom— UI frameworkuuid— session ID generationvite+@vitejs/plugin-react— build tooling
Build
cd packages/chat-client
npm run dev # local dev server on port 5173
npm run build # outputs to dist/ for production
After building, copy dist/ contents to /srv/nexusai on Mini PC 2 for Caddy to serve.
Vite bakes environment variables into the bundle at build time. The .env
file is only needed on the machine running the build, not where files are served.
Environment Variables
| Variable | Required | Default | Description |
|---|---|---|---|
| VITE_ORCHESTRATION_URL | No | '' (empty) |
Orchestration base URL. Leave empty in dev (Vite proxy handles routing). Set to HTTPS domain for production builds. |
Development: leave VITE_ORCHESTRATION_URL unset — the Vite proxy routes
API requests directly to orchestration, bypassing Caddy and Authelia.
Production build: set before running npm run build:
VITE_ORCHESTRATION_URL=https://nexus.jellystorm.com
Do not set
VITE_ORCHESTRATION_URLto the HTTPS domain during local dev. Requests fromlocalhost:5173tonexus.jellystorm.comwill hit Authelia, which returns an HTML login page instead of JSON — causingUnexpected token '<'parse errors inuseModelsanduseSession.
Vite Dev Proxy
vite.config.js proxies API routes directly to the orchestration service
during local development, bypassing Caddy and Authelia entirely:
// vite.config.js
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/models': 'http://192.168.0.205:4000',
'/sessions': 'http://192.168.0.205:4000',
'/chat': 'http://192.168.0.205:4000',
'/projects': 'http://192.168.0.205:4000',
}
}
});
If new routes are added to the orchestration service, add them here too.
Internal Structure
src/
├── api/
│ └── orchestration.js # All fetch calls to the orchestration service
├── config/
│ └── constants.js # FALLBACK_MODELS, DEFAULT_MODEL, API_DEFAULTS
├── hooks/
│ ├── useSession.js # Session list, history loading, active session state
│ ├── useChat.js # Message sending, SSE streaming, message state
│ ├── useModels.js # Dynamic model list fetched from /models endpoint
│ ├── useProjects.js # Project list fetched from /projects endpoint
│ └── useContextMenu.js # Right-click context menu position and visibility
├── components/
│ ├── App.jsx # Root component — layout, shared state, view routing
│ ├── Sidebar.jsx # Left sidebar — projects, recent chats, navigation
│ ├── ChatWindow.jsx # Centre panel — message thread and input bar
│ ├── MessageBubble.jsx # Individual message bubble (user or assistant)
│ ├── InfoPanel.jsx # Right panel — model selector and session metadata
│ ├── SessionModal.jsx # Modal for session rename and delete confirmation
│ ├── ProjectModal.jsx # Modal for project create, edit, and delete confirmation
│ ├── AllChatsView.jsx # Full paginated session list with multi-select bulk delete
│ ├── AllProjectsView.jsx # Project tile grid with create/edit/delete
│ └── SettingsView.jsx # Settings placeholder (sections: Appearance, Memory, Models, About)
├── index.css # Global reset, CSS variables, utility classes
└── main.jsx # React entry point
SessionList.jsxis superseded bySidebar.jsxand kept only as a reference.
Layout
The app uses a view-based layout. App.jsx manages a view state
('chat' | 'all-chats' | 'all-projects' | 'settings') that controls which
main panel is rendered. The left sidebar and right info panel are always present.
┌──────────────────┬──────────────────────────────┐
│ Sidebar │ Main Area (view-dependent) │
│ (collapsible) │ │
│ │ chat → ChatWindow │
│ + New Chat │ all-chats → AllChatsView │
│ ⊞ New Project │ all-projects → AllProjectsView│
│ │ settings → SettingsView │
│ PROJECTS ▾ │ │
│ [tile] [tile] │ │
│ All Projects → │ │
│ │ │
│ RECENT CHATS ▾ │ │
│ Session 1 │ │
│ Session 2 │ │
│ All Chats → │ │
│ │ │
│ ⚙ Settings │ │
└──────────────────┴──────────────────────────────┘
The sidebar collapses to a 48px icon rail. The right info panel (InfoPanel)
slides in from the right over the main area using transform: translateX() —
it is hidden by default (rightOpen starts false) and toggled via a button
in the ChatWindow header.
CSS Architecture
Styles follow a hybrid approach — CSS utility classes for static reusable rules, inline styles for dynamic prop-driven values.
CSS Variables (:root)
| Variable | Value | Description |
|---|---|---|
--bg-base |
#0f1117 |
Page background |
--bg-surface |
#0e0d0d |
Panel backgrounds |
--bg-elevated |
#222536 |
Elevated elements (inputs, cards) |
--border |
#2e3150 |
Border colour |
--accent |
#3d3a79 |
Primary accent (buttons, highlights) |
--accent-hover |
#574fd6 |
Accent hover state |
--text-primary |
#e8e8f0 |
Primary text |
--text-secondary |
#8b8fa8 |
Secondary text |
--text-muted |
#555870 |
Muted / placeholder text |
--bubble-user |
#4742a8 |
User message bubble background |
--bubble-ai |
#20264d |
AI message bubble background |
--sidebar-width |
180px |
Expanded sidebar width |
--panel-width |
200px |
Expanded info panel width |
--header-height |
40px |
Shared header height across all panels |
--radius-sm |
6px |
Small border radius |
--radius-md |
8px |
Medium border radius |
--radius-lg |
12px |
Large border radius |
Utility Classes
| Class | Description |
|---|---|
.panel-header |
Shared header row — used across all panels |
.btn-reset |
Resets button styles (no border, bg, cursor pointer) |
.btn-icon |
Icon button with hover state |
.btn-primary |
Accent-coloured action button with :hover and :disabled states |
.flex / .flex-col |
Flex layout helpers |
.flex-1 / .flex-shrink |
Flex sizing helpers |
.items-center / .justify-center / .justify-between |
Alignment helpers |
.overflow-hidden / .scroll-y |
Overflow helpers |
.text-xs / .text-sm / .text-base |
Font size helpers |
.text-muted / .text-secondary / .text-accent |
Colour helpers |
.label-upper |
Uppercase section label style |
.truncate |
Text overflow ellipsis |
API Layer
All orchestration calls are centralised in src/api/orchestration.js:
| Function | Method | Path | Description |
|---|---|---|---|
fetchSessions |
GET | /sessions | Load session list for sidebar |
fetchSessionHistory |
GET | /sessions/:id/history | Load episode history on session select |
sendMessage |
POST | /chat | Send message, await full response |
streamMessage |
POST | /chat/stream | Send message, receive SSE token stream |
fetchModels |
GET | /models | Load available models from manifest |
renameSession |
PATCH | /sessions/:id | Rename a session |
deleteSession |
DELETE | /sessions/:id | Delete a session |
fetchProjects |
GET | /projects | Load project list |
createProject |
POST | /projects | Create a new project |
updateProject |
PATCH | /projects/:id | Update a project |
deleteProject |
DELETE | /projects/:id | Delete a project |
streamMessage returns an abort function — call it to cancel a stream mid-flight.
Uses a buffer pattern to handle SSE chunks that may span multiple network packets.
Streaming
The chat input sends messages via POST /chat/stream. Tokens arrive as SSE events:
data: {"text":"Hello"}
data: {"text":" Tim"}
data: {"done":true,"model":"gemma-4-26B-A4B-Claude-Distill-APEX-I-Mini.gguf","tokenCount":87}
An empty assistant bubble is appended immediately when the stream opens, then
updated token by token using updateLastMessage. The blinking cursor in
MessageBubble is shown while message.streaming === true and disappears
when the done event is received. Model name and token count from the done
event are stored in useChat state and displayed in the InfoPanel.
Dynamic Model Selector
Available models are fetched from GET /models on mount via the useModels hook.
The hook initialises with FALLBACK_MODELS from constants.js and replaces them
with the server response on success. If the fetch fails, the fallback list is used
silently — a warning is logged to the console.
To add a model, update models.json on the main PC — no client rebuild needed.
FALLBACK_MODELS in constants.js should be kept in sync with models.json
as a reasonable last-resort list in case the endpoint is unreachable.
Session Management
Sessions are identified by external_id — a UUID generated client-side via the
uuid package. New sessions are created locally and auto-registered in the memory
service on the first message. The session list refreshes after each completed
response to surface newly created sessions.
Session Name Display
The chat header and session rows both display session.name if set, falling back
to session.external_id if no name has been assigned:
activeSession.name || activeSession.external_id
Session Actions
Session rows in the sidebar support rename and delete via two entry points:
- Hover — reveals ✎ (rename) and ✕ (delete) icon buttons alongside the row
- Right-click — opens a context menu with the same actions
Both trigger SessionModal — a shared modal component with two modes:
| Mode | Trigger | Behaviour |
|---|---|---|
settings |
Rename button / context menu rename | Shows name input, saves on Enter or Save button |
confirm-delete |
Delete button / context menu delete | Shows confirmation dialog, requires explicit Delete click |
Actions are disabled on unsaved (new) sessions that haven't had a first message sent yet.
Active Session Clearing on Delete
When the deleted session is the currently active one, App.jsx detects the match
and calls selectSession(null) to clear the chat window before refreshing the list:
function handleSessionsChange(deletedSession) {
if (deletedSession?.external_id === activeSession?.external_id) {
selectSession(null);
}
refreshSessions();
}
Context Menu
Implemented via useContextMenu hook — tracks { x, y, session } state and
attaches a window click listener to dismiss on any outside click. Rendered
outside the sidebar div via a React fragment to avoid being clipped by
overflow: hidden.
Button Nesting
Session row action icons (✎ ✕) are rendered as siblings of the session
<button>, not children — HTML does not allow <button> inside <button>.
The outer <div> owns hover state and context menu; the inner <button> handles
session selection; action icon buttons sit alongside it in the same flex row.
Project Management
Projects are a first-class concept in the UI. The useProjects hook fetches
the project list from GET /projects on mount and exposes a refreshProjects
callback for keeping the sidebar in sync after mutations.
Project Actions
Projects are managed from AllProjectsView via ProjectModal:
| Mode | Behaviour |
|---|---|
create |
Name (required), description (optional), colour picker |
edit |
Same fields as create, pre-populated |
confirm-delete |
Confirmation dialog — sessions in the project are not deleted |
The sidebar Projects section shows up to 6 project tiles as coloured badge buttons.
Clicking any tile navigates to AllProjectsView. The "All Projects →" link is
always shown below the tiles.
After any create, edit, or delete in AllProjectsView, onProjectsChange is called
to trigger refreshProjects in App.jsx, keeping the sidebar tiles in sync.
View Routing
App.jsx manages a view state string that controls which main panel renders:
| View | Component | Trigger |
|---|---|---|
'chat' |
ChatWindow |
Default; selecting a session from sidebar or AllChatsView |
'all-chats' |
AllChatsView |
"All Chats →" link or ☰ icon in collapsed rail |
'all-projects' |
AllProjectsView |
"All Projects →" link, ⊞ icon, or New Project button |
'settings' |
SettingsView |
Settings button or ⚙ icon in collapsed rail |
AllChatsView navigates back to 'chat' on session row click, passing the selected
session to selectSession so history loads immediately.