Commit remaining current branch changes

This commit is contained in:
Jason Tudisco 2026-03-17 15:15:47 -06:00
parent c37ff79514
commit 9875378b80
11 changed files with 365 additions and 4 deletions

83
README.md Normal file
View File

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

89
client/README.md Normal file
View File

@ -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).

View File

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

View File

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

View File

@ -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)

View File

@ -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(() => {

View File

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

View File

@ -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))
}

View File

@ -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,

View File

@ -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 })
}

155
server/README.md Normal file
View File

@ -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).