diff --git a/docs/services/chat-client.md b/docs/services/chat-client.md index 480a293..53b5364 100644 --- a/docs/services/chat-client.md +++ b/docs/services/chat-client.md @@ -18,29 +18,63 @@ inference services. Served as static files by Caddy on Mini PC 2. - `vite` + `@vitejs/plugin-react` — build tooling ## Build + ```bash cd packages/chat-client -npm run build # outputs to dist/ 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. -After building, copy `dist/` contents to `/srv/nexusai` on Mini PC 2 for Caddy to serve. - ## Environment Variables | Variable | Required | Default | Description | |---|---|---|---| -| VITE_ORCHESTRATION_URL | No | `''` (empty) | Orchestration base URL. Must be set to the HTTPS domain in production to avoid mixed content errors. | +| VITE_ORCHESTRATION_URL | No | `''` (empty) | Orchestration base URL. Leave empty in dev (Vite proxy handles routing). Set to HTTPS domain for production builds. | -Production value: +**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_URL` to the HTTPS domain during local dev. +> Requests from `localhost:5173` to `nexus.jellystorm.com` will hit Authelia, +> which returns an HTML login page instead of JSON — causing `Unexpected token '<'` +> parse errors in `useModels` and `useSession`. + +## Vite Dev Proxy + +`vite.config.js` proxies API routes directly to the orchestration service +during local development, bypassing Caddy and Authelia entirely: + +```js +// 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', + } + } +}); +``` + +If new routes are added to the orchestration service, add them here too. + ## Internal Structure + ``` src/ ├── api/ @@ -58,7 +92,7 @@ src/ │ ├── 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 dialog for session settings (rename) +│ └── SessionModal.jsx # Modal dialog for session settings and delete confirmation ├── index.css # Global reset, CSS variables, utility classes └── main.jsx # React entry point ``` @@ -66,6 +100,7 @@ src/ ## Layout Three-panel layout with collapsible sidebars: + ``` ┌─────────────────┬──────────────────────────┬─────────────┐ │ Session List │ Chat Window │ Info Panel │ @@ -92,19 +127,19 @@ rules, inline styles for dynamic prop-driven values. | Variable | Value | Description | |---|---|---| | `--bg-base` | `#0f1117` | Page background | -| `--bg-surface` | `#1a1d27` | Panel backgrounds | +| `--bg-surface` | `#0e0d0d` | Panel backgrounds | | `--bg-elevated` | `#222536` | Elevated elements (inputs, cards) | | `--border` | `#2e3150` | Border colour | -| `--accent` | `#6c63ff` | Primary accent (buttons, highlights) | +| `--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` | `#6c63ff` | User message bubble background | -| `--bubble-ai` | `#222536` | AI message bubble background | -| `--sidebar-width` | `280px` | Expanded sidebar width | -| `--panel-width` | `260px` | Expanded info panel width | -| `--header-height` | `56px` | Shared header height across all panels | +| `--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 | @@ -146,6 +181,7 @@ Uses a buffer pattern to handle SSE chunks that may span multiple network packet ## Streaming The chat input sends messages via `POST /chat/stream`. Tokens arrive as SSE events: + ``` data: {"text":"Hello"} data: {"text":" Tim"} @@ -165,16 +201,10 @@ The hook initialises with `FALLBACK_MODELS` from `constants.js` and replaces the with the server response on success. If the fetch fails, the fallback list is used silently — a warning is logged to the console. -```js -// constants.js -export const FALLBACK_MODELS = [ - { value: 'companion:latest', label: 'Companion' }, - // ... -]; -``` +To add a model, update `models.json` on the main PC — no client rebuild needed. -The selected model is passed with every chat request. 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 @@ -183,24 +213,58 @@ Sessions are identified by `external_id` — a UUID generated client-side via th 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 list both display `session.name` if set, falling back +to `session.external_id` if no name has been assigned: + +```js +activeSession.name || activeSession.external_id +``` + ### Session Actions -The session list supports rename and delete: +The session list supports rename and delete via two entry points: -- **Hover** — reveals ✎ (rename) and ✕ (delete) icon buttons on the session row +- **Hover** — reveals ✎ (rename) and ✕ (delete) icon buttons alongside the session row - **Right-click** — opens a context menu with the same actions -Rename opens a `SessionModal` dialog. The modal is designed to expand into a full -session settings panel in future — the title is already "Session Settings" to -reflect this intent. +Both trigger `SessionModal` — a shared modal component with two modes: -Delete is immediate with no confirmation dialog (planned for a future update). +| 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 with session name, requires explicit Delete button click | -Actions are disabled on unsaved (new) sessions that haven't had a message sent yet. +The modal is intentionally titled "Session Settings" and structured to expand +into a full settings panel in future iterations. + +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: + +```js +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 React fragment) to avoid being clipped by -`overflow: hidden`. \ No newline at end of file +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 +` - - - {/* New chat button */} -
- -
- - {/* Session list */} -
- {isOpen && sessions.map(session => { - const isActive = activeSession?.external_id === session.external_id; - const isHovered = hoveredId === session.external_id; - - return ( -
setHoveredId(session.external_id)} - onMouseLeave={() => setHoveredId(null)} - onContextMenu={e => !session.isNew && openMenu(e, session)} - style={{ - position: 'relative', - background: isActive ? 'var(--bg-elevated)' : isHovered ? 'var(--bg-elevated)' : 'transparent', - borderLeft: isActive ? '2px solid var(--accent)' : '2px solid transparent', - transition: 'background 0.1s', - }} - > - - -
- ) : ( - - {formatDate(session.updated_at)} - - )} -
- {session.isNew && ( - Unsaved - )} - - - ); - })} - - {isOpen && sessions.length === 0 && ( -
- No conversations yet -
- )} - + {/* Header */} +
+ {isOpen && Conversations} +
- {/* Context menu */} - {menu && ( -
e.stopPropagation()} - style={{ - position: 'fixed', - top: menu.y, - left: menu.x, - background: 'var(--bg-elevated)', - border: '1px solid var(--border)', - borderRadius: 'var(--radius-md)', - padding: '4px', - zIndex: 50, - minWidth: '140px', - }} - > - - -
- )} + {/* New chat button */} +
+ +
- {/* Rename modal */} - {modalSession && ( - setModalSession(null)} - /> - )} - - ); + {/* Session list */} +
+ {isOpen && sessions.map(session => { + const isActive = activeSession?.external_id === session.external_id; + const isHovered = hoveredId === session.external_id; + + return ( +
setHoveredId(session.external_id)} + onMouseLeave={() => setHoveredId(null)} + onContextMenu={e => !session.isNew && openMenu(e, session)} + style={{ + position: 'relative', + display: 'flex', + alignItems: 'stretch', + background: isActive ? 'var(--bg-elevated)' : isHovered ? 'var(--bg-elevated)' : 'transparent', + borderLeft: isActive ? '2px solid var(--accent)' : '2px solid transparent', + transition: 'background 0.1s', + }} + > + {/* Session select button — no action icons inside */} + + + {/* Action icons — outside the button, alongside it */} + {isHovered && !session.isNew && ( +
+ + +
+ )} +
+ ); + })} + + {isOpen && sessions.length === 0 && ( +
+ No conversations yet +
+ )} +
+ + + {/* Context menu */} + {menu && ( +
e.stopPropagation()} + style={{ + position: 'fixed', + top: menu.y, + left: menu.x, + background: 'var(--bg-elevated)', + border: '1px solid var(--border)', + borderRadius: 'var(--radius-md)', + padding: '4px', + zIndex: 50, + minWidth: '140px', + }} + > + + +
+ )} + + {/* Rename modal */} + {modalSession && ( + setModalSession(null)} + /> + )} + + ); } \ No newline at end of file diff --git a/packages/chat-client/src/components/SessionModal.jsx b/packages/chat-client/src/components/SessionModal.jsx index f6ac3ac..58bb865 100644 --- a/packages/chat-client/src/components/SessionModal.jsx +++ b/packages/chat-client/src/components/SessionModal.jsx @@ -1,108 +1,116 @@ -import React, {useState, useEffect, useRef} from "react"; +// SessionModal.jsx +import React, {useState, useEffect, useRef} from 'react'; -export default function SessionModal({ session, onRename, onClose}) { +export default function SessionModal({ session, mode = 'settings', onRename, onDelete, onClose }) { const [name, setName] = useState(session?.name || ''); - const inputRef = useRef(null) + const inputRef = useRef(null); - //Focus input when modal opens useEffect(() => { - inputRef.current?.focus(); - inputRef.current?.select(); - }, []); + if (mode === 'settings') { + inputRef.current?.focus(); + inputRef.current?.select(); + } + }, [mode]); function handleSubmit() { const trimmed = name.trim(); if (!trimmed) return; - onRename(session, trimmed); onClose(); } function handleKeyDown(e) { - if (e.key === 'Enter') handleSubmit(); - if(e.key === 'Escape') onClose(); - + if (e.key === 'Enter' && mode === 'settings') handleSubmit(); + if (e.key === 'Escape') onClose(); } if (!session) return null; + return ( - //Backdrop -
- {/* Modal - stop click propagating to backdrop */} -
e.stopPropagation()} - style={{ - background: 'var(--bg-surface)', - border: '1px solid var(--border)', - borderRadius: 'var(--radius-lg)', - padding: '24px', - width: '360px', - display: 'flex', - flexDirection: 'column', - gap: '16px', - }} - > - {/* Modal Header*/} -

- Session Settings -

- - {/* Modal Session name input*/} -
- - setName(e.target.value)} - onKeyDown={handleKeyDown} - placeholder="Enter session name..." - style={{ - background: 'var(--bg-elevated)', - border: '1px solid var(--border)', - borderRadius: 'var(--radius-md)', - padding: '8px 12px', - color: 'var(--text-primary)', - fontSize: '14px', - outline: 'none', - width: '100%', - }} - /> -
- - {/* Modal Buttons*/} -
- - -
- +
+
e.stopPropagation()} onKeyDown={handleKeyDown} style={{ + background: 'var(--bg-surface)', + border: '1px solid var(--border)', + borderRadius: 'var(--radius-lg)', + padding: '24px', + width: '360px', + display: 'flex', + flexDirection: 'column', + gap: '16px', + }}> + {mode === 'settings' ? ( + <> +

+ Session Settings +

+
+ + setName(e.target.value)} + placeholder="Enter session name..." + style={{ + background: 'var(--bg-elevated)', + border: '1px solid var(--border)', + borderRadius: 'var(--radius-md)', + padding: '8px 12px', + color: 'var(--text-primary)', + fontSize: '14px', + outline: 'none', + width: '100%', + }} + /> +
+
+ + +
+ + ) : ( + <> +

+ Delete Session +

+

+ Are you sure you want to delete{' '} + + {session.name || session.external_id} + + ? This will permanently remove all messages in this conversation. +

+
+ + +
+ + )}
-
- - - - ) + ); } \ No newline at end of file diff --git a/packages/chat-client/vite.config.js b/packages/chat-client/vite.config.js index 41c1568..c7cd74e 100644 --- a/packages/chat-client/vite.config.js +++ b/packages/chat-client/vite.config.js @@ -11,6 +11,7 @@ export default defineConfig({ proxy: { '/chat': 'http://192.168.0.205:4000', '/sessions': 'http://192.168.0.205:4000', + '/models': 'http://192.168.0.205:4000', }, }, }); \ No newline at end of file