Commit remaining current branch changes
This commit is contained in:
parent
c37ff79514
commit
9875378b80
83
README.md
Normal file
83
README.md
Normal 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
89
client/README.md
Normal 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).
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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(() => {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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))
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
155
server/README.md
Normal 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).
|
||||
Loading…
x
Reference in New Issue
Block a user