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 {
|
export default {
|
||||||
state: {
|
state: {
|
||||||
|
// Auth + top-level app view state.
|
||||||
user: null,
|
user: null,
|
||||||
authView: 'login',
|
authView: 'login',
|
||||||
|
|
||||||
|
// Active chat data.
|
||||||
rooms: [],
|
rooms: [],
|
||||||
activeRoomId: null,
|
activeRoomId: null,
|
||||||
activeRoom: null,
|
activeRoom: null,
|
||||||
messages: [],
|
messages: [],
|
||||||
|
|
||||||
|
// Modal visibility.
|
||||||
showCreateModal: false,
|
showCreateModal: false,
|
||||||
showInviteModal: false,
|
showInviteModal: false,
|
||||||
showDeleteModal: false,
|
showDeleteModal: false,
|
||||||
showClearModal: false,
|
showClearModal: false,
|
||||||
showProfileModal: false,
|
showProfileModal: false,
|
||||||
|
|
||||||
|
// Live AI / typing state for the currently open room.
|
||||||
aiTyping: false,
|
aiTyping: false,
|
||||||
aiToolStatus: null,
|
aiToolStatus: null,
|
||||||
streamingMessage: null,
|
streamingMessage: null,
|
||||||
typingUsers: [],
|
typingUsers: [],
|
||||||
|
|
||||||
|
// Used when a shared message link cannot be opened.
|
||||||
linkError: null,
|
linkError: null,
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -314,7 +323,8 @@
|
|||||||
})
|
})
|
||||||
this.scrollToBottom()
|
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) {
|
if (this.state.streamingMessage?.id === this._streamMsgId) {
|
||||||
this.update({
|
this.update({
|
||||||
@ -356,6 +366,8 @@
|
|||||||
|
|
||||||
ws.on('user_typing', (msg) => {
|
ws.on('user_typing', (msg) => {
|
||||||
if (msg.room_id === this.state.activeRoomId && msg.user_id !== this.state.user.id) {
|
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)
|
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 })
|
users.push({ user_id: msg.user_id, display_name: msg.display_name })
|
||||||
this.update({ typingUsers: users })
|
this.update({ typingUsers: users })
|
||||||
@ -439,6 +451,7 @@
|
|||||||
linkError: null,
|
linkError: null,
|
||||||
})
|
})
|
||||||
ws.joinRoom(roomId)
|
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
|
// Only scroll to bottom if not navigating to a specific message
|
||||||
if (!this._pendingScrollHash) {
|
if (!this._pendingScrollHash) {
|
||||||
this.scrollToBottom()
|
this.scrollToBottom()
|
||||||
@ -529,6 +542,7 @@
|
|||||||
|
|
||||||
scrollToBottom() {
|
scrollToBottom() {
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
|
// Query the DOM after the frame so Riot has already rendered the latest message list.
|
||||||
const container = document.querySelector('.messages-list')
|
const container = document.querySelector('.messages-list')
|
||||||
if (container) {
|
if (container) {
|
||||||
container.scrollTop = container.scrollHeight
|
container.scrollTop = container.scrollHeight
|
||||||
|
|||||||
@ -527,6 +527,7 @@
|
|||||||
},
|
},
|
||||||
|
|
||||||
onMounted() {
|
onMounted() {
|
||||||
|
// Close the members popover when clicking anywhere outside the header controls.
|
||||||
this._closeMembers = (e) => {
|
this._closeMembers = (e) => {
|
||||||
if (this.state.showMembers && !this.$('.members-toggle')?.contains(e.target) && !this.$('.members-dropdown')?.contains(e.target)) {
|
if (this.state.showMembers && !this.$('.members-toggle')?.contains(e.target) && !this.$('.members-dropdown')?.contains(e.target)) {
|
||||||
this.update({ showMembers: false })
|
this.update({ showMembers: false })
|
||||||
@ -555,7 +556,7 @@
|
|||||||
textarea.style.height = Math.min(textarea.scrollHeight, 120) + 'px'
|
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 || []
|
const users = this.props.typingUsers || []
|
||||||
let typingDisplay = ''
|
let typingDisplay = ''
|
||||||
if (users.length === 1) {
|
if (users.length === 1) {
|
||||||
@ -586,6 +587,8 @@
|
|||||||
const items = e.clipboardData?.items
|
const items = e.clipboardData?.items
|
||||||
if (!items) return
|
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) {
|
for (const item of items) {
|
||||||
if (item.type.startsWith('image/')) {
|
if (item.type.startsWith('image/')) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@ -651,6 +654,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Extract mentions (@ai or @{aiName} detection)
|
// 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 mentions = []
|
||||||
const lc = content.toLowerCase()
|
const lc = content.toLowerCase()
|
||||||
const aiName = this.props.room?.ai_name?.toLowerCase() || ''
|
const aiName = this.props.room?.ai_name?.toLowerCase() || ''
|
||||||
@ -667,6 +671,7 @@
|
|||||||
const result = await api.uploadChatImage(this.state.pendingImage)
|
const result = await api.uploadChatImage(this.state.pendingImage)
|
||||||
imageUrl = result.url
|
imageUrl = result.url
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
// Keep the draft visible so the user can retry instead of losing the message.
|
||||||
console.error('Failed to upload image:', err)
|
console.error('Failed to upload image:', err)
|
||||||
this.update({ uploading: false })
|
this.update({ uploading: false })
|
||||||
return
|
return
|
||||||
|
|||||||
@ -207,13 +207,13 @@
|
|||||||
// 1. Get challenge
|
// 1. Get challenge
|
||||||
const { challenge } = await api.nostrChallenge()
|
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
|
const nonce = JSON.parse(atob(challenge.split('.')[1])).nonce
|
||||||
|
|
||||||
// 2. Get pubkey
|
// 2. Get pubkey
|
||||||
const pubkey = await getPublicKey()
|
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)
|
const profile = await fetchNostrProfile(pubkey)
|
||||||
|
|
||||||
// 4. Build unsigned event (NIP-07 compatible)
|
// 4. Build unsigned event (NIP-07 compatible)
|
||||||
|
|||||||
@ -401,6 +401,7 @@
|
|||||||
if (this.props.isStreaming) return // Don't markdown-render while streaming
|
if (this.props.isStreaming) return // Don't markdown-render while streaming
|
||||||
const el = this.$('.message-content.markdown-content')
|
const el = this.$('.message-content.markdown-content')
|
||||||
if (el && this.props.message?.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)
|
el.innerHTML = renderMarkdown(this.props.message.content)
|
||||||
// Inject copy buttons into code blocks
|
// Inject copy buttons into code blocks
|
||||||
el.querySelectorAll('pre').forEach((pre) => {
|
el.querySelectorAll('pre').forEach((pre) => {
|
||||||
@ -470,6 +471,7 @@
|
|||||||
const toggle = e.currentTarget
|
const toggle = e.currentTarget
|
||||||
const body = toggle.nextElementSibling
|
const body = toggle.nextElementSibling
|
||||||
const isOpen = toggle.classList.contains('open')
|
const isOpen = toggle.classList.contains('open')
|
||||||
|
// Keep the collapse logic local to each item so tool results stay independent.
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
toggle.classList.remove('open')
|
toggle.classList.remove('open')
|
||||||
body.classList.add('collapsed')
|
body.classList.add('collapsed')
|
||||||
@ -507,6 +509,7 @@
|
|||||||
if (!hash) return
|
if (!hash) return
|
||||||
const roomId = this.props.message?.room_id
|
const roomId = this.props.message?.room_id
|
||||||
if (!roomId) return
|
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 url = `${window.location.origin}${window.location.pathname}#${roomId}/${hash}`
|
||||||
const btn = e.currentTarget
|
const btn = e.currentTarget
|
||||||
navigator.clipboard.writeText(url).then(() => {
|
navigator.clipboard.writeText(url).then(() => {
|
||||||
|
|||||||
@ -236,6 +236,7 @@
|
|||||||
},
|
},
|
||||||
|
|
||||||
onMounted() {
|
onMounted() {
|
||||||
|
// Copy the current profile value into local form state so edits stay cancelable.
|
||||||
this.update({
|
this.update({
|
||||||
displayName: this.props.user?.display_name || '',
|
displayName: this.props.user?.display_name || '',
|
||||||
})
|
})
|
||||||
@ -264,6 +265,7 @@
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await api.updateProfile({ display_name: this.state.displayName })
|
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)
|
saveAuth(data.token, data.user)
|
||||||
this.props.cbProfileUpdate(data.user)
|
this.props.cbProfileUpdate(data.user)
|
||||||
this.update({ saving: false, success: 'Profile updated!' })
|
this.update({ saving: false, success: 'Profile updated!' })
|
||||||
@ -276,6 +278,7 @@
|
|||||||
const file = e.target.files[0]
|
const file = e.target.files[0]
|
||||||
if (!file) return
|
if (!file) return
|
||||||
|
|
||||||
|
// Keep avatar uploads small because they are stored and served by the app itself.
|
||||||
if (file.size > 2 * 1024 * 1024) {
|
if (file.size > 2 * 1024 * 1024) {
|
||||||
this.update({ error: 'Avatar must be under 2MB' })
|
this.update({ error: 'Avatar must be under 2MB' })
|
||||||
return
|
return
|
||||||
|
|||||||
@ -12,11 +12,13 @@ function getToken() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function authHeaders() {
|
function authHeaders() {
|
||||||
|
// Keep auth header creation in one place so every request follows the same rule.
|
||||||
const token = getToken()
|
const token = getToken()
|
||||||
return token ? { Authorization: `Bearer ${token}` } : {}
|
return token ? { Authorization: `Bearer ${token}` } : {}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function request(method, path, body) {
|
async function request(method, path, body) {
|
||||||
|
// Most client API calls are JSON and share the same error handling path.
|
||||||
const opts = {
|
const opts = {
|
||||||
method,
|
method,
|
||||||
headers: {
|
headers: {
|
||||||
@ -79,6 +81,7 @@ export const api = {
|
|||||||
createRoom: (data) => request('POST', '/rooms', data),
|
createRoom: (data) => request('POST', '/rooms', data),
|
||||||
getRoom: (roomId) => request('GET', `/rooms/${roomId}`),
|
getRoom: (roomId) => request('GET', `/rooms/${roomId}`),
|
||||||
getMessages: (roomId, limit = 50, before) => {
|
getMessages: (roomId, limit = 50, before) => {
|
||||||
|
// `before` supports paginating older messages without changing the base endpoint.
|
||||||
const params = new URLSearchParams({ limit: String(limit) })
|
const params = new URLSearchParams({ limit: String(limit) })
|
||||||
if (before) params.set('before', before)
|
if (before) params.set('before', before)
|
||||||
return request('GET', `/rooms/${roomId}/messages?${params}`)
|
return request('GET', `/rooms/${roomId}/messages?${params}`)
|
||||||
@ -118,6 +121,7 @@ export const api = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function saveAuth(token, user) {
|
export function saveAuth(token, user) {
|
||||||
|
// Token + user stay together so the UI can repaint immediately on refresh.
|
||||||
localStorage.setItem('token', token)
|
localStorage.setItem('token', token)
|
||||||
localStorage.setItem('user', JSON.stringify(user))
|
localStorage.setItem('user', JSON.stringify(user))
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import MarkdownIt from 'markdown-it'
|
import MarkdownIt from 'markdown-it'
|
||||||
import hljs from 'highlight.js'
|
import hljs from 'highlight.js'
|
||||||
|
|
||||||
|
// One shared renderer keeps markdown output consistent everywhere messages appear.
|
||||||
const md = new MarkdownIt({
|
const md = new MarkdownIt({
|
||||||
html: false,
|
html: false,
|
||||||
linkify: true,
|
linkify: true,
|
||||||
|
|||||||
@ -10,6 +10,7 @@ class WebSocketManager {
|
|||||||
this.reconnectDelay = 1000
|
this.reconnectDelay = 1000
|
||||||
this.maxReconnectDelay = 30000
|
this.maxReconnectDelay = 30000
|
||||||
this.token = null
|
this.token = null
|
||||||
|
// Track joined rooms so reconnect can restore live updates automatically.
|
||||||
this.subscribedRooms = new Set()
|
this.subscribedRooms = new Set()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -55,6 +56,8 @@ class WebSocketManager {
|
|||||||
// Code 4401 = custom auth failure
|
// Code 4401 = custom auth failure
|
||||||
// Also detect immediate close without open (HTTP 401 on upgrade)
|
// Also detect immediate close without open (HTTP 401 on upgrade)
|
||||||
if (event.code === 1008 || event.code === 4401 || !event.wasClean) {
|
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 we never successfully connected, the token is likely invalid
|
||||||
if (this._authFailed || (!event.wasClean && this.reconnectDelay > 4000)) {
|
if (this._authFailed || (!event.wasClean && this.reconnectDelay > 4000)) {
|
||||||
console.warn('[WS] Auth appears invalid, stopping reconnect')
|
console.warn('[WS] Auth appears invalid, stopping reconnect')
|
||||||
@ -116,6 +119,7 @@ class WebSocketManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
joinRoom(roomId) {
|
joinRoom(roomId) {
|
||||||
|
// Joining is idempotent: we keep the room in the set and let the server ignore duplicates.
|
||||||
this.subscribedRooms.add(roomId)
|
this.subscribedRooms.add(roomId)
|
||||||
this.send({ type: 'join_room', room_id: 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