diff --git a/README.md b/README.md new file mode 100644 index 0000000..7a04869 --- /dev/null +++ b/README.md @@ -0,0 +1,83 @@ +# GroupChat2 + +GroupChat2 is a full-stack group chat application with live rooms, invite flows, profile management, image uploads, and an AI participant that can respond in-room using OpenRouter plus web tools. + +The project is split into two main apps: + +- [Client README](./client/README.md) +- [Server README](./server/README.md) + +## What It Does + +- Real-time group chat over WebSockets +- Account registration and login with JWT auth +- Optional Nostr-based authentication +- Room creation, membership, invite links, and Nostr invites +- AI-assisted rooms with configurable model, prompt, and assistant name +- Streaming AI responses with tool usage indicators +- Chat image uploads and user avatar uploads +- Message permalinks backed by stored message hashes +- SQLite persistence with automatic startup backups + +## Project Layout + +```text +GroupChat2/ +|- client/ Riot.js + Vite single-page app +|- server/ Rust + Axum API, WebSocket server, SQLite storage +|- dev.ps1 Windows dev runner +|- dev.sh macOS/Linux dev runner +|- prod.sh Production build/run script +``` + +## Stack + +- Frontend: Riot.js, Vite, vanilla JS, markdown-it, highlight.js +- Backend: Rust, Axum, Tokio, SQLx, SQLite +- AI: OpenRouter with optional Tavily or Brave search +- Auth: JWT, Argon2 passwords, Nostr challenge/verify flow + +## Quick Start + +1. Copy `server/.env.example` to `server/.env`. +2. Fill in at least `OPENROUTER_API_KEY` and the search provider keys required by `SEARCH_PROVIDER`. +3. Start the app with one of the repo scripts: + +```powershell +./dev.ps1 +``` + +```bash +./dev.sh +``` + +If you prefer running each side manually: + +```bash +cd server +cargo run +``` + +```bash +cd client +npm install +npm run dev +``` + +Default local ports: + +- Windows dev script: client `http://localhost:3000`, server `http://localhost:3001` +- macOS/Linux dev script: client `http://localhost:3003`, server `http://localhost:3002` +- Manual defaults: client `http://localhost:3000`, server `http://localhost:3001` + +## Development Notes + +- The client proxies `/api`, `/ws`, and `/uploads` to the server during local development. +- The server serves `client/dist` directly in production. +- Database migrations are executed at server startup. +- Before opening the SQLite database, the server creates a timestamped backup and retains the 10 most recent copies. + +## Where To Read Next + +- [Client README](./client/README.md) for UI structure, frontend commands, and browser behavior +- [Server README](./server/README.md) for env vars, endpoints, storage, and backend architecture diff --git a/client/README.md b/client/README.md new file mode 100644 index 0000000..e8eff33 --- /dev/null +++ b/client/README.md @@ -0,0 +1,89 @@ +# GroupChat2 Client + +The client is a Riot.js single-page application that handles authentication, room navigation, chat rendering, invite flows, profile editing, and the live chat experience on top of the server's REST and WebSocket APIs. + +## Stack + +- Riot.js components +- Vite dev/build pipeline +- Vanilla JavaScript +- `markdown-it` and `highlight.js` for message rendering + +## Responsibilities + +- Render login and registration flows +- Show the room list, active chat, and profile UI +- Connect to the server over WebSockets for live updates +- Stream AI responses into the message list as they arrive +- Support invite links and message permalink navigation +- Upload avatars and chat images through the API +- Handle token expiry by logging the user out cleanly + +## Commands + +```bash +npm install +npm run dev +``` + +```bash +npm run build +``` + +```bash +npm run preview +``` + +By default Vite runs on port `3000`. + +## Dev Server Behavior + +The Vite config proxies these paths to the backend: + +- `/api` +- `/ws` +- `/uploads` + +Relevant runtime vars: + +- `VITE_PORT`: client port, default `3000` +- `VITE_API_PORT`: backend port, default `3001` + +This lets the frontend use relative paths in development and production. + +## App Structure + +```text +src/ +|- components/ Riot UI components for auth, rooms, messages, modals, profile +|- services/ API wrapper, WebSocket manager, markdown, avatar, Nostr helpers +|- styles/ Global styles +|- main.js Component registration and app mount +``` + +Main UI pieces: + +- `app.riot`: top-level auth state, room state, modal state, and message link handling +- `chat-sidebar.riot`: room list and account actions +- `chat-room.riot`: main conversation view and composer +- `message-bubble.riot`: message rendering, hashes, metadata, and markdown output +- `profile-page.riot`: display name and avatar management + +## Runtime Notes + +- Authentication state is stored in `localStorage`. +- Pending invite tokens and message permalinks are staged through `sessionStorage` when needed. +- The WebSocket layer automatically reconnects and re-joins subscribed rooms. +- AI output is streamed chunk-by-chunk and rendered progressively before the final stored message arrives. +- Message links support both `#roomId/messageHash` and older hash-only navigation. + +## Integration Contract + +The client expects the backend to provide: + +- JWT auth endpoints under `/api/auth/*` +- Room, message, invite, upload, and model endpoints under `/api/*` +- A WebSocket endpoint at `/ws?token=...` +- Uploaded media under `/uploads/*` + +For backend setup and env configuration, see the [Server README](../server/README.md). diff --git a/client/src/components/app.riot b/client/src/components/app.riot index c00e796..7dcdb79 100644 --- a/client/src/components/app.riot +++ b/client/src/components/app.riot @@ -196,21 +196,30 @@ export default { state: { + // Auth + top-level app view state. user: null, authView: 'login', + + // Active chat data. rooms: [], activeRoomId: null, activeRoom: null, messages: [], + + // Modal visibility. showCreateModal: false, showInviteModal: false, showDeleteModal: false, showClearModal: false, showProfileModal: false, + + // Live AI / typing state for the currently open room. aiTyping: false, aiToolStatus: null, streamingMessage: null, typingUsers: [], + + // Used when a shared message link cannot be opened. linkError: null, }, @@ -314,7 +323,8 @@ }) this.scrollToBottom() }, - // onDone: buffer fully drained after stream ended + // onDone: buffer fully drained after stream ended. + // At this point the UI can remove the "still typing" cursor effect. () => { if (this.state.streamingMessage?.id === this._streamMsgId) { this.update({ @@ -356,6 +366,8 @@ ws.on('user_typing', (msg) => { if (msg.room_id === this.state.activeRoomId && msg.user_id !== this.state.user.id) { + // Replace any existing entry for the same user so repeated typing events + // refresh the timeout instead of duplicating the person in the UI. const users = this.state.typingUsers.filter(u => u.user_id !== msg.user_id) users.push({ user_id: msg.user_id, display_name: msg.display_name }) this.update({ typingUsers: users }) @@ -439,6 +451,7 @@ linkError: null, }) ws.joinRoom(roomId) + // Joining after room data loads avoids subscribing the UI to a room that failed to open. // Only scroll to bottom if not navigating to a specific message if (!this._pendingScrollHash) { this.scrollToBottom() @@ -529,6 +542,7 @@ scrollToBottom() { requestAnimationFrame(() => { + // Query the DOM after the frame so Riot has already rendered the latest message list. const container = document.querySelector('.messages-list') if (container) { container.scrollTop = container.scrollHeight diff --git a/client/src/components/chat-room.riot b/client/src/components/chat-room.riot index e81321d..1e7aa05 100644 --- a/client/src/components/chat-room.riot +++ b/client/src/components/chat-room.riot @@ -527,6 +527,7 @@ }, onMounted() { + // Close the members popover when clicking anywhere outside the header controls. this._closeMembers = (e) => { if (this.state.showMembers && !this.$('.members-toggle')?.contains(e.target) && !this.$('.members-dropdown')?.contains(e.target)) { this.update({ showMembers: false }) @@ -555,7 +556,7 @@ textarea.style.height = Math.min(textarea.scrollHeight, 120) + 'px' } - // Build typing display text + // Convert the raw typing user list into the small sentence shown under messages. const users = this.props.typingUsers || [] let typingDisplay = '' if (users.length === 1) { @@ -586,6 +587,8 @@ const items = e.clipboardData?.items if (!items) return + // Pasted screenshots feel like file attachments to the user, + // so they go through the same preview/upload path as picked files. for (const item of items) { if (item.type.startsWith('image/')) { e.preventDefault() @@ -651,6 +654,7 @@ } // Extract mentions (@ai or @{aiName} detection) + // The server only needs to know whether the AI was mentioned, not the exact text match. const mentions = [] const lc = content.toLowerCase() const aiName = this.props.room?.ai_name?.toLowerCase() || '' @@ -667,6 +671,7 @@ const result = await api.uploadChatImage(this.state.pendingImage) imageUrl = result.url } catch (err) { + // Keep the draft visible so the user can retry instead of losing the message. console.error('Failed to upload image:', err) this.update({ uploading: false }) return diff --git a/client/src/components/login-page.riot b/client/src/components/login-page.riot index 4ac53f0..cc2b667 100644 --- a/client/src/components/login-page.riot +++ b/client/src/components/login-page.riot @@ -207,13 +207,13 @@ // 1. Get challenge const { challenge } = await api.nostrChallenge() - // Decode JWT payload to get nonce (middle segment) + // The signed Nostr event must echo the server nonce from the JWT payload. const nonce = JSON.parse(atob(challenge.split('.')[1])).nonce // 2. Get pubkey const pubkey = await getPublicKey() - // 3. Fetch profile (best-effort) + // 3. Fetch profile (best-effort) so first login can prefill display name/avatar. const profile = await fetchNostrProfile(pubkey) // 4. Build unsigned event (NIP-07 compatible) diff --git a/client/src/components/message-bubble.riot b/client/src/components/message-bubble.riot index 39cb247..60b0766 100644 --- a/client/src/components/message-bubble.riot +++ b/client/src/components/message-bubble.riot @@ -401,6 +401,7 @@ if (this.props.isStreaming) return // Don't markdown-render while streaming const el = this.$('.message-content.markdown-content') if (el && this.props.message?.content) { + // Riot renders the container, then markdown-it fills in the trusted HTML output. el.innerHTML = renderMarkdown(this.props.message.content) // Inject copy buttons into code blocks el.querySelectorAll('pre').forEach((pre) => { @@ -470,6 +471,7 @@ const toggle = e.currentTarget const body = toggle.nextElementSibling const isOpen = toggle.classList.contains('open') + // Keep the collapse logic local to each item so tool results stay independent. if (isOpen) { toggle.classList.remove('open') body.classList.add('collapsed') @@ -507,6 +509,7 @@ if (!hash) return const roomId = this.props.message?.room_id if (!roomId) return + // Include room id in the fragment so shared links can open the room directly. const url = `${window.location.origin}${window.location.pathname}#${roomId}/${hash}` const btn = e.currentTarget navigator.clipboard.writeText(url).then(() => { diff --git a/client/src/components/profile-page.riot b/client/src/components/profile-page.riot index 5dffb1c..6ab126f 100644 --- a/client/src/components/profile-page.riot +++ b/client/src/components/profile-page.riot @@ -236,6 +236,7 @@ }, onMounted() { + // Copy the current profile value into local form state so edits stay cancelable. this.update({ displayName: this.props.user?.display_name || '', }) @@ -264,6 +265,7 @@ try { const data = await api.updateProfile({ display_name: this.state.displayName }) + // Server returns a refreshed token/user pair, so persist both together. saveAuth(data.token, data.user) this.props.cbProfileUpdate(data.user) this.update({ saving: false, success: 'Profile updated!' }) @@ -276,6 +278,7 @@ const file = e.target.files[0] if (!file) return + // Keep avatar uploads small because they are stored and served by the app itself. if (file.size > 2 * 1024 * 1024) { this.update({ error: 'Avatar must be under 2MB' }) return diff --git a/client/src/services/api.js b/client/src/services/api.js index 96894e2..961370a 100644 --- a/client/src/services/api.js +++ b/client/src/services/api.js @@ -12,11 +12,13 @@ function getToken() { } function authHeaders() { + // Keep auth header creation in one place so every request follows the same rule. const token = getToken() return token ? { Authorization: `Bearer ${token}` } : {} } async function request(method, path, body) { + // Most client API calls are JSON and share the same error handling path. const opts = { method, headers: { @@ -79,6 +81,7 @@ export const api = { createRoom: (data) => request('POST', '/rooms', data), getRoom: (roomId) => request('GET', `/rooms/${roomId}`), getMessages: (roomId, limit = 50, before) => { + // `before` supports paginating older messages without changing the base endpoint. const params = new URLSearchParams({ limit: String(limit) }) if (before) params.set('before', before) return request('GET', `/rooms/${roomId}/messages?${params}`) @@ -118,6 +121,7 @@ export const api = { } export function saveAuth(token, user) { + // Token + user stay together so the UI can repaint immediately on refresh. localStorage.setItem('token', token) localStorage.setItem('user', JSON.stringify(user)) } diff --git a/client/src/services/markdown.js b/client/src/services/markdown.js index 91a4b9a..18ab183 100644 --- a/client/src/services/markdown.js +++ b/client/src/services/markdown.js @@ -1,6 +1,7 @@ import MarkdownIt from 'markdown-it' import hljs from 'highlight.js' +// One shared renderer keeps markdown output consistent everywhere messages appear. const md = new MarkdownIt({ html: false, linkify: true, diff --git a/client/src/services/websocket.js b/client/src/services/websocket.js index 0a68302..9351a2c 100644 --- a/client/src/services/websocket.js +++ b/client/src/services/websocket.js @@ -10,6 +10,7 @@ class WebSocketManager { this.reconnectDelay = 1000 this.maxReconnectDelay = 30000 this.token = null + // Track joined rooms so reconnect can restore live updates automatically. this.subscribedRooms = new Set() } @@ -55,6 +56,8 @@ class WebSocketManager { // Code 4401 = custom auth failure // Also detect immediate close without open (HTTP 401 on upgrade) if (event.code === 1008 || event.code === 4401 || !event.wasClean) { + // Two failed attempts in a row usually means the saved token is stale, + // so the app should stop reconnecting and send the user back to login. // If we never successfully connected, the token is likely invalid if (this._authFailed || (!event.wasClean && this.reconnectDelay > 4000)) { console.warn('[WS] Auth appears invalid, stopping reconnect') @@ -116,6 +119,7 @@ class WebSocketManager { } joinRoom(roomId) { + // Joining is idempotent: we keep the room in the set and let the server ignore duplicates. this.subscribedRooms.add(roomId) this.send({ type: 'join_room', room_id: roomId }) } diff --git a/server/README.md b/server/README.md new file mode 100644 index 0000000..04ce0be --- /dev/null +++ b/server/README.md @@ -0,0 +1,155 @@ +# GroupChat2 Server + +The server is a Rust/Axum application that provides authentication, room and message APIs, WebSocket chat delivery, AI response orchestration, file uploads, and SQLite-backed persistence for GroupChat2. + +## Stack + +- Rust 2021 +- Axum and Tokio +- SQLx with SQLite +- JWT auth and Argon2 password hashing +- OpenRouter for AI completions +- Tavily or Brave for web search tools + +## Responsibilities + +- Register and authenticate users +- Support Nostr challenge/verify login +- Manage rooms, members, invites, and profile updates +- Persist messages, hashes, room settings, and AI metadata +- Broadcast real-time events over WebSockets +- Stream AI responses and tool usage events to connected clients +- Store uploaded avatars and chat images +- Serve built frontend assets in production + +## Requirements + +- Rust toolchain with `cargo` +- A valid `OPENROUTER_API_KEY` +- A search provider configured through either Tavily or Brave + +## Environment + +Copy `.env.example` to `.env` and fill in the values you need. + +```bash +cp .env.example .env +``` + +Important variables: + +- `BIND_ADDR`: server bind address, default `0.0.0.0:3001` +- `RUST_LOG`: log level, default `info` +- `DATABASE_URL`: SQLite connection string, default `sqlite:chat.db?mode=rwc` +- `JWT_SECRET`: JWT signing secret +- `OPENROUTER_API_KEY`: required +- `SEARCH_PROVIDER`: `tavily` or `brave` +- `TAVILY_API_KEY`: required when `SEARCH_PROVIDER=tavily` +- `BRAVE_API_KEY`: required when `SEARCH_PROVIDER=brave` +- `STATIC_DIR`: optional path to built client assets for production serving + +## Commands + +Run the backend directly: + +```bash +cargo run +``` + +Build a release binary: + +```bash +cargo build --release +``` + +From the repo root, production build/run is also available through: + +```bash +./prod.sh +``` + +## Startup Behavior + +On startup the server: + +- loads environment variables from `.env` +- validates required AI/search configuration +- creates a timestamped backup of the SQLite database if it already exists +- keeps the 10 most recent backups +- opens the SQLite database +- applies SQL migrations embedded in the binary + +## API Surface + +Main routes exposed by the server: + +- `/api/auth/register` +- `/api/auth/login` +- `/api/auth/me` +- `/api/auth/profile` +- `/api/auth/avatar` +- `/api/auth/nostr/challenge` +- `/api/auth/nostr/verify` +- `/api/rooms` +- `/api/rooms/:room_id` +- `/api/rooms/:room_id/messages` +- `/api/rooms/:room_id/join` +- `/api/rooms/:room_id/clear` +- `/api/messages/hash/:hash` +- `/api/models` +- `/api/invites` +- `/api/invites/:token/accept` +- `/api/invites/nostr` +- `/api/upload` +- `/ws` +- `/uploads/*` + +## Real-Time Flow + +WebSocket clients connect to `/ws` with a JWT token in the query string. + +Client messages include: + +- `join_room` +- `typing` +- `send_message` + +Server events include: + +- new messages +- AI typing notifications +- AI stream chunks and stream end markers +- AI tool usage updates +- user typing notifications +- room deleted and room cleared events + +## Storage Layout + +```text +server/ +|- migrations/ SQL schema and incremental changes +|- src/ handlers, middleware, models, services +|- uploads/ avatars and chat images +|- backups/ automatic database backups +|- chat.db SQLite database +``` + +Uploaded file behavior: + +- avatars are stored under `uploads/avatars/` +- chat images are stored under `uploads/chat-images/` +- avatar uploads are limited to 2 MB +- chat image uploads are limited to 5 MB + +## AI Behavior + +When a user mentions the assistant or the room is configured to always respond, the server: + +- loads recent room history +- includes image context for human-uploaded images when available +- calls OpenRouter in streaming mode +- executes tool calls for search or page fetches +- broadcasts streaming output to the room +- stores the final AI response with usage metadata + +For frontend behavior and local browser setup, see the [Client README](../client/README.md).