Compare commits

..

19 Commits

Author SHA1 Message Date
62fc32658c Merge branch 'nostr-login' into master 2026-03-17 15:16:14 -06:00
9875378b80 Commit remaining current branch changes 2026-03-17 15:15:47 -06:00
c37ff79514 Document server code paths 2026-03-17 15:14:04 -06:00
927d106eae Add provider-based web search with Tavily support
- Add `SEARCH_PROVIDER` config with Tavily/Brave API key validation in server and prod script
- Introduce unified `web_search` tool and shared search service with Tavily + Brave backends
- Update chat UI tool status/result labels to treat both search tools consistently
2026-03-16 20:18:27 -06:00
16bc3315eb feat: show full date/time and unix timestamp on time hover
Hovering over the message time now shows a tooltip with the full
human-readable date/time and the unix timestamp below it.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 19:21:24 -06:00
b963c96915 feat: show abbreviated message hash in chat bubble header
Displays first 7 chars of SHA-256 hash after sender name and time.
Full hash visible on hover via title attribute.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 19:17:20 -06:00
66bbc44f75 fix: double messages on re-login, nostr profile fetch, show npub in profile
- Fix duplicate messages after logout→re-login: ws.disconnect() now clears
  event listeners so initChat() doesn't stack duplicate handlers
- Nostr profile fetch: race multiple relays (damus, nostr.band, nos.lol)
  for better reliability
- Add nostr_pubkey field to UserPublic — returned from me/login/rooms APIs
- Profile page shows truncated npub instead of email for Nostr users
- Avatar service handles external URLs (Nostr profile pictures)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 19:13:23 -06:00
cd8ef7dbf6 fix: split SQLite migration 008 into ALTER TABLE + CREATE UNIQUE INDEX
SQLite does not support ADD COLUMN with inline UNIQUE constraint.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 18:53:25 -06:00
1a2f0e7951 feat: add Nostr NIP-07 browser extension login and invite by pubkey
- Server: nostr crate, migration 008 (nostr_pubkey column), challenge/verify
  endpoints for Schnorr-signed NIP-07 auth, invite-by-nostr endpoint
- Client: NIP-07 extension detection, relay profile fetch, Nostr login button
  on login/register pages, Nostr tab in invite modal, profile page handles
  no-email Nostr users
- Sentinel emails (nostr:<prefix>) hidden at API boundary via public_email()

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 18:43:01 -06:00
9634c275b3 style: add glowing border to permalink-highlighted messages
The linked message now gets a purple box-shadow glow and background
highlight that lasts 4 seconds, making it much more obvious which
message the user was linked to.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 17:48:03 -06:00
55c17b2999 fix: support hash-only permalink format with server-side resolution
Add /api/messages/hash/:hash endpoint that resolves a message hash to
its room ID (with membership check). The client now handles both
#roomId/hash and #hash formats - the latter calls the API to find
which room the message belongs to, then loads it and scrolls.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 17:44:44 -06:00
e630cca6c6 fix: message permalinks work on fresh page load and handle permissions
- Stash permalink hash in sessionStorage before login so it survives
  the auth flow and navigates after login completes
- Wait for DOM render (double rAF) before scrolling to target message
- Skip scrollToBottom when navigating via permalink
- Show error screen for 403 (no access) and 404 (room not found)
- Attach HTTP status code to API errors for proper error differentiation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 17:40:18 -06:00
Jason Tudisco
5a0d26745a chore: gitignore .claude/settings.local.json to avoid cross-machine conflicts
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 17:34:25 -06:00
7210acf032 fix: include room ID in message permalink for cross-room navigation
Links now use #roomId/messageHash format so the app can load the correct
room before scrolling to the target message. Handles hashchange events
and auto-navigates on page load.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 17:31:12 -06:00
2e1a0ac858 feat: add SHA-256 integrity hashes to messages with copy/link buttons
Add a hash column to messages table computed from SHA-256(created_at + content)
to ensure message integrity. Existing messages get backfilled during migration.
All messages now show copy and permalink buttons on hover, with hash-based
URL fragments that auto-scroll and highlight the target message.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 17:11:03 -06:00
Jason Tudisco
6cb423b342 feat: add database backup on startup and isolate dev environment
Add automatic SQLite database backup before migrations on every server
start. Backups are timestamped and stored in backups/, with WAL/SHM
files included. Old backups are pruned to keep only the 10 most recent.

Update dev.sh to use separate ports and database from production so both
can run simultaneously on the same machine:
  - Production: server :3001, DB chat.db
  - Development: server :3002, Vite :3003, DB chat-dev.db

Make Vite config dynamic via VITE_PORT and VITE_API_PORT env vars.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 08:27:32 -06:00
Jason Tudisco
07b4df5544 feat: add profile page with avatar upload and image paste in chat
Add user profile page with custom avatar upload (crop/resize to 256px),
avatar display throughout the app, and MD5-based Gravatar fallback.

Add image paste/attach support in chat with vision model detection via
OpenRouter API. Images can be pasted from clipboard or selected via file
picker, uploaded to the server, and sent alongside messages. The AI
receives images as base64 data URLs for multimodal models. Models with
vision support are indicated with a badge in the model picker.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 08:24:38 -06:00
Jason Tudisco
1c7d4d0510 feat: add production deployment and macOS dev scripts
Add dev.sh for running server + client in parallel on macOS, and prod.sh
for building and deploying in production. The Rust server now serves
static client files with SPA fallback, eliminating the need for Vite in
production. Also adds missing BRAVE_API_KEY to .env.example.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 07:27:06 -06:00
39aaa96a99 feat: add Gravatar avatars with monsterid fallback for user identification
- Add avatar_hash (MD5 of email) to MessagePayload for server-side hash computation
- Create avatar.js with Gravatar URL generation and client-side MD5 implementation
- Show sender names and unique avatars on all messages including own messages
- Use monsterid fallback for users without Gravatar, robot icons reserved for AI
- LEFT JOIN users table in message history queries for avatar hash lookup

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 06:41:48 -06:00
51 changed files with 4044 additions and 360 deletions

View File

@ -1,42 +0,0 @@
{
"permissions": {
"allow": [
"Bash(cargo check)",
"Bash(rustc --version)",
"Bash(cargo tree -p axum)",
"Bash(cargo build)",
"Bash(taskkill /IM groupchat-server.exe /F)",
"Bash(powershell -Command \"Stop-Process -Name groupchat-server -Force -ErrorAction SilentlyContinue; Start-Sleep -Seconds 1; Write-Host ''Done''\")",
"Bash(powershell -Command \"Start-Process -FilePath ''cargo'' -ArgumentList ''run'' -WorkingDirectory \\(Get-Location\\) -WindowStyle Hidden\")",
"Bash(powershell -Command \"Start-Sleep -Seconds 3; Test-NetConnection -ComputerName localhost -Port 3001 -WarningAction SilentlyContinue | Select-Object TcpTestSucceeded\")",
"Bash(powershell -Command \"try { $r = Invoke-WebRequest -Uri ''http://localhost:3001/api/rooms'' -Method GET -Headers @{''Content-Type''=''application/json''} -ErrorAction Stop; Write-Host ''Status:'' $r.StatusCode } catch { Write-Host ''Error:'' $_Exception.Message }\")",
"Bash(curl -s -o /dev/null -w \"%{http_code}\" http://localhost:3001/api/rooms)",
"Bash(powershell -Command \"Stop-Process -Name groupchat-server -Force -ErrorAction SilentlyContinue; Stop-Process -Name cargo -Force -ErrorAction SilentlyContinue; Start-Sleep -Seconds 1; Write-Host ''Stopped''\")",
"Bash(powershell -Command \"Start-Process -FilePath ''./target/debug/groupchat-server.exe'' -WorkingDirectory \\(Get-Location\\) -NoNewWindow -PassThru | Select-Object Id\")",
"Bash(curl -s -w \"\\\\nHTTP %{http_code}\" http://localhost:3001/api/rooms)",
"Bash(powershell -Command \"Stop-Process -Name groupchat-server -Force -ErrorAction SilentlyContinue; Start-Sleep -Seconds 1; Write-Host ''Killed''\")",
"Bash(powershell -Command \"Get-Process -Name groupchat-server -ErrorAction SilentlyContinue | Select-Object Id, ProcessName\")",
"Bash(powershell -Command \"Stop-Process -Name groupchat-server -Force -ErrorAction SilentlyContinue; Write-Host ''Server stopped''\")",
"Bash(powershell -Command \"Get-NetTCPConnection -LocalPort 3001 -ErrorAction SilentlyContinue | Select-Object OwningProcess; Get-NetTCPConnection -LocalPort 5173 -ErrorAction SilentlyContinue | Select-Object OwningProcess\")",
"Bash(powershell -Command \"Set-Location ''Z:\\\\Projects\\\\Hot\\\\Duke\\\\GroupChat2\\\\server''; cargo build 2>&1\")",
"WebFetch(domain:openrouter.ai)",
"Bash(powershell -Command \"Stop-Process -Name groupchat-server -Force -ErrorAction SilentlyContinue; Start-Sleep -Seconds 1; Set-Location ''Z:\\\\Projects\\\\Hot\\\\Duke\\\\GroupChat2\\\\server''; cargo build 2>&1\")",
"Bash(taskkill /F /IM groupchat-server.exe)",
"mcp__Desktop_Commander__read_file",
"WebFetch(domain:emschwartz.me)",
"WebFetch(domain:github.com)",
"WebFetch(domain:api.search.brave.com)",
"WebFetch(domain:pypi.org)",
"WebFetch(domain:api-dashboard.search.brave.com)",
"WebFetch(domain:community.brave.app)",
"Bash(powershell -Command \"Get-Process groupchat-server -ErrorAction SilentlyContinue | Stop-Process -Force; Start-Sleep -Seconds 2; Write-Output ''Killed''\")",
"Bash(powershell -Command \"Get-Process groupchat-server -ErrorAction SilentlyContinue | Stop-Process -Force; Start-Sleep -Seconds 1; Write-Output ''Killed''\")",
"Bash(cargo run)",
"Bash(timeout 15)",
"Bash(powershell -Command \"Get-Process groupchat-server -ErrorAction SilentlyContinue | Stop-Process -Force; Write-Output ''Killed''\")",
"Bash(powershell -Command \"Get-Process groupchat-server -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue; Start-Sleep -Seconds 2; Set-Location ''Z:\\\\Projects\\\\Hot\\\\Duke\\\\GroupChat2\\\\server''; cargo build 2>&1\")",
"Bash(git init)",
"Bash(git add -A)"
]
}
}

11
.gitignore vendored
View File

@ -3,6 +3,14 @@ server/target/
server/chat.db
server/chat.db-journal
server/chat.db-wal
server/chat.db-shm
server/chat-dev.db
server/chat-dev.db-journal
server/chat-dev.db-wal
server/chat-dev.db-shm
# Database backups
backups/
# Node
client/node_modules/
@ -12,6 +20,9 @@ client/dist/
.env
server/.env
# Claude Code (machine-specific)
.claude/settings.local.json
# IDE
.vscode/
.idea/

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

@ -901,7 +901,6 @@
"integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~5.26.4"
}

View File

@ -15,6 +15,7 @@
user={state.user}
cb-select-room={selectRoom}
cb-create-room={() => update({ showCreateModal: true })}
cb-profile={() => update({ showProfileModal: true })}
cb-logout={handleLogout}
/>
<main class="chat-main">
@ -32,7 +33,18 @@
cb-delete-room={() => update({ showDeleteModal: true })}
cb-clear-room={() => update({ showClearModal: true })}
/>
<div if={!state.activeRoom} class="no-room">
<div if={state.linkError} class="no-room">
<div class="no-room-content link-error">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<circle cx="12" cy="12" r="10"/>
<line x1="4.93" y1="4.93" x2="19.07" y2="19.07"/>
</svg>
<h2>{state.linkError}</h2>
<p>The message link you followed could not be opened.</p>
<button class="btn btn-ghost" onclick={() => update({ linkError: null })}>Dismiss</button>
</div>
</div>
<div if={!state.activeRoom && !state.linkError} class="no-room">
<div class="no-room-content">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
@ -69,6 +81,13 @@
cb-confirm={confirmClearRoom}
cb-close={() => update({ showClearModal: false })}
/>
<profile-page
if={state.showProfileModal}
user={state.user}
cb-profile-update={handleProfileUpdate}
cb-close={() => update({ showProfileModal: false })}
/>
</template>
</div>
@ -138,50 +157,125 @@
.no-room-content p {
font-size: var(--text-sm);
}
.link-error svg {
color: var(--error, #e53e3e);
opacity: 0.8;
}
.link-error h2 {
color: var(--error, #e53e3e);
}
.link-error .btn {
margin-top: var(--space-md);
}
:global(.hash-highlight) {
animation: hash-flash 4s ease;
border-radius: 8px;
box-shadow: 0 0 0 2px rgba(108, 92, 231, 0.5), 0 0 12px rgba(108, 92, 231, 0.2);
}
@keyframes hash-flash {
0%, 30% {
background: rgba(108, 92, 231, 0.15);
box-shadow: 0 0 0 2px rgba(108, 92, 231, 0.5), 0 0 16px rgba(108, 92, 231, 0.25);
}
100% {
background: transparent;
box-shadow: none;
}
}
</style>
<script>
import { api, saveAuth, getUser, clearAuth, isAuthenticated } from '../services/api.js'
import { api, saveAuth, getUser, clearAuth, isAuthenticated, setOnUnauthorized } from '../services/api.js'
import { ws } from '../services/websocket.js'
import { StreamBuffer } from '../services/stream-buffer.js'
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,
},
onMounted() {
// Register global 401 handler so any expired-token API call triggers logout
setOnUnauthorized(() => this.handleLogout())
// Listen for hash changes to navigate to linked messages
this._onHashChange = () => {
this.navigateToMessageLink()
}
window.addEventListener('hashchange', this._onHashChange)
const user = getUser()
if (user && isAuthenticated()) {
this.update({ user })
this.initChat()
// Verify the token is still valid with the server before trusting it
this.verifyAndInit(user)
} else {
// Not logged in — store invite token so we can accept after login
// Not logged in — stash permalink and invite for after login
this.stashPendingLink()
this.checkPendingInvite()
}
},
async verifyAndInit(cachedUser) {
try {
// Ask the server if our stored token is still good
const freshUser = await api.me()
// Use the fresh data from the server (display_name etc. may have changed)
this.update({ user: freshUser })
this.initChat()
} catch (err) {
// Token is expired or invalid — force back to login screen
console.warn('Stored token is no longer valid, logging out:', err.message)
clearAuth()
ws.disconnect()
this.update({ user: null })
this.checkPendingInvite()
}
},
onUnmounted() {
ws.disconnect()
window.removeEventListener('hashchange', this._onHashChange)
},
async initChat() {
const token = localStorage.getItem('token')
ws.connect(token)
// If WebSocket detects auth failure (expired/invalid token), auto-logout
ws.on('auth_failed', () => {
console.warn('WebSocket auth failed — logging out')
this.handleLogout()
})
ws.on('new_message', (msg) => {
if (msg.message.room_id === this.state.activeRoomId) {
// If we were streaming this message, cancel the buffer and remove placeholder
@ -224,11 +318,13 @@
mentions: [],
is_ai: true,
streaming: true,
avatar_hash: '',
},
})
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({
@ -270,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 })
@ -307,6 +405,9 @@
// Process any pending invite token
await this.processInviteToken()
// Check for a message permalink in the URL hash (e.g. #roomId/messageHash)
this.navigateToMessageLink()
},
handleLogin(data) {
@ -328,7 +429,6 @@
},
async selectRoom(roomId) {
try {
// Cancel any active stream buffer when switching rooms
if (this.streamBuffer) {
this.streamBuffer.cancel()
@ -348,11 +448,13 @@
aiToolStatus: null,
streamingMessage: null,
typingUsers: [],
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()
} catch (e) {
console.error('Failed to load room:', e)
}
},
@ -367,8 +469,8 @@
}
},
sendMessage({ content, mentions }) {
ws.sendMessage(this.state.activeRoomId, content, mentions)
sendMessage({ content, mentions, imageUrl }) {
ws.sendMessage(this.state.activeRoomId, content, mentions, imageUrl)
},
handleDeleteRoom(roomId) {
@ -387,6 +489,18 @@
this.update({ messages: [], showClearModal: false })
},
handleProfileUpdate(user) {
this.update({ user })
},
/** Stash a message permalink hash so it survives the login flow */
stashPendingLink() {
const fragment = window.location.hash?.slice(1)
if (fragment) {
sessionStorage.setItem('pendingMessageLink', fragment)
}
},
/** Check URL for /invite/:token and stash it for after login if needed */
checkPendingInvite() {
const match = window.location.pathname.match(/^\/invite\/(.+)$/)
@ -428,12 +542,90 @@
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
}
})
},
/** Parse #roomId/messageHash or #messageHash from URL or sessionStorage, load the room, and scroll */
async navigateToMessageLink() {
// Try URL hash first, then sessionStorage (for post-login flow)
let fragment = window.location.hash?.slice(1)
if (!fragment) {
fragment = sessionStorage.getItem('pendingMessageLink')
sessionStorage.removeItem('pendingMessageLink')
}
if (!fragment) return
let roomId, msgHash
if (fragment.includes('/')) {
// New format: #roomId/messageHash
const slashIdx = fragment.indexOf('/')
roomId = fragment.slice(0, slashIdx)
msgHash = fragment.slice(slashIdx + 1)
} else {
// Old format: #messageHash — resolve room via API
msgHash = fragment
try {
const result = await api.resolveMessageHash(msgHash)
roomId = result.room_id
} catch (e) {
const status = e?.status
if (status === 404) {
this.update({ linkError: 'Message not found or you don\'t have access' })
} else {
this.update({ linkError: 'Could not find this message' })
}
window.history.replaceState(null, '', window.location.pathname)
return
}
}
if (!roomId || !msgHash) return
// Store the target hash so selectRoom skips scrollToBottom
this._pendingScrollHash = msgHash
// Load the room if not already active
if (this.state.activeRoomId !== roomId) {
try {
await this.selectRoom(roomId)
} catch (e) {
this._pendingScrollHash = null
const status = e?.status
if (status === 403) {
this.update({ linkError: 'You don\'t have access to this room' })
} else if (status === 404) {
this.update({ linkError: 'Room not found' })
} else {
this.update({ linkError: 'Could not load this room' })
}
window.history.replaceState(null, '', window.location.pathname)
return
}
}
// Wait for DOM to render the messages, then scroll
this._pendingScrollHash = null
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)))
this.scrollToHash(msgHash)
// Clean the hash from URL after navigating
window.history.replaceState(null, '', window.location.pathname)
},
scrollToHash(hash) {
const el = document.getElementById('msg-' + hash)
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
el.classList.add('hash-highlight')
setTimeout(() => el.classList.remove('hash-highlight'), 4000)
}
},
}
</script>
</app>

View File

@ -54,7 +54,15 @@
<span class="member-role ai-role">AI</span>
</div>
<div each={member in props.room?.members} key={member.id} class="member-item">
<div class="member-avatar">{member.display_name?.charAt(0).toUpperCase()}</div>
<div class="member-avatar">
<img src={avatarFromEmail(member.email, 28)}
alt={member.display_name}
width="28"
height="28"
class="avatar-img"
loading="lazy"
/>
</div>
<span class="member-name">{member.display_name}</span>
<span if={member.id === props.room?.created_by} class="member-role">Owner</span>
</div>
@ -64,7 +72,7 @@
<!-- Messages -->
<div class="messages-list" ref="messagesList">
<div class="messages-spacer"></div>
<div each={msg in props.messages} key={msg.id}>
<div each={msg in props.messages} key={msg.id} data-hash={msg.hash || ''} id={msg.hash ? 'msg-' + msg.hash : ''}>
<message-bubble
message={msg}
is-own={msg.sender_id === props.user?.id}
@ -93,7 +101,7 @@
</div>
<template if={props.aiToolStatus}>
<span class="tool-status-text">
{props.aiToolStatus.tool === 'brave_search' ? '🔍 Searching...' : props.aiToolStatus.tool === 'web_fetch' ? '🌐 Reading page...' : '⚙️ Using tool...'}
{isSearchTool(props.aiToolStatus.tool) ? '🔍 Searching...' : props.aiToolStatus.tool === 'web_fetch' ? '🌐 Reading page...' : '⚙️ Using tool...'}
</span>
</template>
<template if={!props.aiToolStatus}>
@ -110,7 +118,25 @@
<!-- Input -->
<div class="message-input-area">
<!-- Image preview -->
<div if={state.pendingImage} class="image-preview-bar">
<img src={state.pendingImagePreview} alt="Preview" class="image-preview-thumb" />
<span class="image-preview-name">{state.pendingImage?.name || 'Pasted image'}</span>
<button class="image-preview-remove" onclick={removeImage} title="Remove image">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<div class="input-wrapper">
<button class="btn btn-ghost attach-btn" onclick={triggerFileInput} title="Attach image">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
<circle cx="8.5" cy="8.5" r="1.5"/>
<polyline points="21 15 16 10 5 21"/>
</svg>
</button>
<input type="file" ref="fileInput" accept="image/png,image/jpeg,image/gif,image/webp" style="display:none" onchange={handleFileSelect} />
<textarea
ref="input"
class="message-input"
@ -118,11 +144,12 @@
rows="1"
oninput={handleInput}
onkeydown={handleKeydown}
onpaste={handlePaste}
></textarea>
<button
class="btn btn-primary send-btn"
onclick={handleSend}
disabled={!state.inputValue?.trim()}
disabled={!state.inputValue?.trim() && !state.pendingImage}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/>
@ -222,6 +249,14 @@
font-size: var(--text-xs);
color: var(--text-secondary);
flex-shrink: 0;
overflow: hidden;
}
.avatar-img {
width: 100%;
height: 100%;
border-radius: var(--radius-full);
object-fit: cover;
}
.member-item .ai-avatar {
@ -362,6 +397,53 @@
border-top: 1px solid var(--border);
}
.image-preview-bar {
display: flex;
align-items: center;
gap: var(--space-sm);
padding: var(--space-sm) var(--space-sm);
margin-bottom: var(--space-xs);
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: var(--radius-md);
animation: fadeIn 0.15s ease;
}
.image-preview-thumb {
width: 48px;
height: 48px;
object-fit: cover;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
}
.image-preview-name {
flex: 1;
font-size: var(--text-xs);
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.image-preview-remove {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
padding: 4px;
border-radius: var(--radius-sm);
display: flex;
align-items: center;
justify-content: center;
transition: all var(--transition-fast);
}
.image-preview-remove:hover {
color: var(--error, #e53e3e);
background: rgba(229, 62, 62, 0.1);
}
.input-wrapper {
display: flex;
align-items: flex-end;
@ -377,6 +459,22 @@
border-color: var(--accent);
}
.attach-btn {
width: 36px;
height: 36px;
padding: 0;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-md);
color: var(--text-muted);
}
.attach-btn:hover {
color: var(--accent);
}
.message-input {
flex: 1;
border: none;
@ -413,15 +511,23 @@
<script>
import { ws } from '../services/websocket.js'
import { api } from '../services/api.js'
import { avatarFromEmail as _avatarFromEmail } from '../services/avatar.js'
export default {
avatarFromEmail: _avatarFromEmail,
state: {
inputValue: '',
typingDisplay: '',
showMembers: false,
pendingImage: null,
pendingImagePreview: null,
uploading: false,
},
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 })
@ -432,6 +538,9 @@
onUnmounted() {
document.removeEventListener('click', this._closeMembers)
if (this.state.pendingImagePreview) {
URL.revokeObjectURL(this.state.pendingImagePreview)
}
},
toggleMembers(e) {
@ -447,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) {
@ -474,9 +583,61 @@
}
},
handleSend() {
const content = this.state.inputValue?.trim()
if (!content) return
handlePaste(e) {
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()
const file = item.getAsFile()
if (file) this.attachImage(file)
return
}
}
},
triggerFileInput() {
this.$('[ref="fileInput"]')?.click()
},
handleFileSelect(e) {
const file = e.target.files?.[0]
if (file && file.type.startsWith('image/')) {
this.attachImage(file)
}
e.target.value = '' // reset so same file can be re-selected
},
attachImage(file) {
// Revoke old preview URL
if (this.state.pendingImagePreview) {
URL.revokeObjectURL(this.state.pendingImagePreview)
}
const preview = URL.createObjectURL(file)
this.update({
pendingImage: file,
pendingImagePreview: preview,
})
},
removeImage() {
if (this.state.pendingImagePreview) {
URL.revokeObjectURL(this.state.pendingImagePreview)
}
this.update({
pendingImage: null,
pendingImagePreview: null,
})
},
async handleSend() {
const content = this.state.inputValue?.trim() || ''
const hasImage = !!this.state.pendingImage
if (!content && !hasImage) return
// Intercept /clear command — room creator only
if (content === '/clear') {
@ -493,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() || ''
@ -500,8 +662,34 @@
mentions.push('ai-assistant')
}
this.props.cbSend({ content, mentions })
this.update({ inputValue: '' })
let imageUrl = null
// Upload image first if attached
if (hasImage) {
try {
this.update({ uploading: true })
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
}
}
// Clean up image preview
if (this.state.pendingImagePreview) {
URL.revokeObjectURL(this.state.pendingImagePreview)
}
this.props.cbSend({ content, mentions, imageUrl })
this.update({
inputValue: '',
pendingImage: null,
pendingImagePreview: null,
uploading: false,
})
const textarea = this.$('textarea')
if (textarea) {
@ -509,6 +697,10 @@
textarea.style.height = 'auto'
}
},
isSearchTool(toolName) {
return toolName === 'web_search' || toolName === 'brave_search'
},
}
</script>
</chat-room>

View File

@ -32,9 +32,15 @@
</div>
<div class="sidebar-footer">
<div class="user-info">
<div class="user-info user-info-clickable" onclick={props.cbProfile} title="Edit profile">
<div class="user-avatar">
{props.user?.display_name?.charAt(0).toUpperCase()}
<img src={getUserAvatar()}
alt={props.user?.display_name}
width="32"
height="32"
class="avatar-img"
loading="lazy"
/>
</div>
<span class="user-name">{props.user?.display_name}</span>
</div>
@ -172,18 +178,36 @@
min-width: 0;
}
.user-info-clickable {
cursor: pointer;
padding: var(--space-xs);
border-radius: var(--radius-md);
transition: background var(--transition-fast);
}
.user-info-clickable:hover {
background: var(--bg-hover);
}
.user-avatar {
width: 32px;
height: 32px;
border-radius: var(--radius-full);
background: var(--accent);
color: white;
background: var(--bg-elevated);
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: var(--text-sm);
flex-shrink: 0;
overflow: hidden;
}
.avatar-img {
width: 100%;
height: 100%;
border-radius: var(--radius-full);
object-fit: cover;
}
.user-name {
@ -195,6 +219,12 @@
</style>
<script>
export default {}
import { getAvatarUrl } from '../services/avatar.js'
export default {
getUserAvatar() {
return getAvatarUrl(this.props.user, 32)
},
}
</script>
</chat-sidebar>

View File

@ -65,6 +65,7 @@
<div class="model-option-meta">
<span class="model-option-id">{model.id}</span>
<span if={model.context_length} class="model-option-ctx">{formatCtx(model.context_length)}</span>
<span if={model.supports_vision} class="model-option-vision" title="Supports image input">👁 Vision</span>
</div>
</div>
</div>
@ -345,6 +346,15 @@
padding: 1px 5px;
border-radius: var(--radius-sm);
}
.model-option-vision {
background: rgba(99, 102, 241, 0.15);
color: var(--accent);
padding: 1px 6px;
border-radius: var(--radius-sm);
font-size: 10px;
font-weight: 500;
}
</style>
<script>

View File

@ -10,7 +10,18 @@
</button>
</div>
<form if={!state.inviteUrl} onsubmit={handleSubmit}>
<div class="invite-tabs">
<button
class={'invite-tab' + (state.mode === 'email' ? ' active' : '')}
onclick={() => update({ mode: 'email', error: null, nostrResult: null })}
>Email</button>
<button
class={'invite-tab' + (state.mode === 'nostr' ? ' active' : '')}
onclick={() => update({ mode: 'nostr', error: null, inviteUrl: null })}
>Nostr</button>
</div>
<form if={state.mode === 'email' && !state.inviteUrl} onsubmit={handleSubmit}>
<div class="form-group">
<label for="invite-email">Email address</label>
<input
@ -33,7 +44,7 @@
</div>
</form>
<div if={state.inviteUrl} class="invite-success">
<div if={state.mode === 'email' && state.inviteUrl} class="invite-success">
<p>Invite link generated!</p>
<div class="invite-link-box">
<code>{state.inviteUrl}</code>
@ -44,6 +55,39 @@
<button class="btn btn-primary" onclick={props.cbClose}>Done</button>
</div>
</div>
<form if={state.mode === 'nostr' && !state.nostrResult} onsubmit={handleNostrInvite}>
<div class="form-group">
<label for="invite-npub">Nostr public key</label>
<input
type="text"
id="invite-npub"
placeholder="npub1... or hex pubkey"
value={state.nostrPubkey}
oninput={e => update({ nostrPubkey: e.target.value })}
required
/>
</div>
<p if={state.error} class="error-text">{state.error}</p>
<div class="modal-actions">
<button type="button" class="btn btn-ghost" onclick={props.cbClose}>Cancel</button>
<button type="submit" class="btn btn-primary" disabled={state.nostrLoading}>
{state.nostrLoading ? 'Adding...' : 'Add to Room'}
</button>
</div>
</form>
<div if={state.mode === 'nostr' && state.nostrResult} class="invite-success">
<p if={state.nostrResult === 'added'} class="success-msg">Added {state.nostrDisplayName} to room</p>
<p if={state.nostrResult === 'not_found'} class="info-msg">
This Nostr user hasn't joined GroupChat yet. They'll need to log in with their Nostr extension first.
</p>
<div class="modal-actions">
<button class="btn btn-primary" onclick={props.cbClose}>Done</button>
</div>
</div>
</div>
</div>
@ -150,6 +194,49 @@
font-size: var(--text-xs);
color: var(--text-muted);
}
.invite-tabs {
display: flex;
gap: var(--space-xs);
margin-bottom: var(--space-lg);
border-bottom: 1px solid var(--border);
padding-bottom: var(--space-xs);
}
.invite-tab {
background: none;
border: none;
padding: var(--space-xs) var(--space-md);
font-size: var(--text-sm);
color: var(--text-secondary);
cursor: pointer;
border-radius: var(--radius-md) var(--radius-md) 0 0;
transition: color var(--transition-fast), background var(--transition-fast);
}
.invite-tab:hover {
color: var(--text-primary);
background: var(--bg-tertiary);
}
.invite-tab.active {
color: var(--accent);
border-bottom: 2px solid var(--accent);
font-weight: 500;
}
.success-msg {
color: var(--success);
font-weight: 500;
margin-bottom: var(--space-md);
}
.info-msg {
color: var(--text-secondary);
font-size: var(--text-sm);
line-height: 1.5;
margin-bottom: var(--space-md);
}
</style>
<script>
@ -157,10 +244,15 @@
export default {
state: {
mode: 'email',
email: '',
nostrPubkey: '',
error: null,
loading: false,
nostrLoading: false,
inviteUrl: null,
nostrResult: null,
nostrDisplayName: '',
},
handleOverlayClick() {
@ -185,6 +277,25 @@
}
},
async handleNostrInvite(e) {
e.preventDefault()
this.update({ nostrLoading: true, error: null })
try {
const result = await api.inviteByNostr({
room_id: this.props.roomId,
nostr_pubkey: this.state.nostrPubkey.trim(),
})
this.update({
nostrResult: result.status,
nostrDisplayName: result.display_name || '',
nostrLoading: false,
})
} catch (err) {
this.update({ error: err.message, nostrLoading: false })
}
},
copyLink() {
navigator.clipboard.writeText(this.state.inviteUrl)
},

View File

@ -38,6 +38,17 @@
</button>
</form>
<div if={state.hasNostr} class="nostr-divider">
<span>or</span>
</div>
<button if={state.hasNostr} class="btn btn-nostr btn-full" onclick={handleNostrLogin} disabled={state.nostrLoading}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 8px; vertical-align: -2px;">
<path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/>
</svg>
{state.nostrLoading ? 'Connecting...' : 'Login with Nostr'}
</button>
<p class="auth-footer">
Don't have an account?
<a href="#" onclick={e => { e.preventDefault(); props.cbSwitch() }}>Register</a>
@ -104,6 +115,47 @@
margin-bottom: var(--space-sm);
}
.nostr-divider {
display: flex;
align-items: center;
gap: var(--space-md);
margin: var(--space-lg) 0;
color: var(--text-muted);
font-size: var(--text-sm);
}
.nostr-divider::before,
.nostr-divider::after {
content: '';
flex: 1;
height: 1px;
background: var(--border);
}
.btn-nostr {
background: #8B5CF6;
color: white;
border: none;
padding: var(--space-sm) var(--space-md);
border-radius: var(--radius-md);
font-size: var(--text-sm);
font-weight: 500;
cursor: pointer;
transition: background var(--transition-fast);
display: flex;
align-items: center;
justify-content: center;
}
.btn-nostr:hover:not(:disabled) {
background: #7C3AED;
}
.btn-nostr:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.auth-footer {
text-align: center;
margin-top: var(--space-lg);
@ -114,6 +166,7 @@
<script>
import { api } from '../services/api.js'
import { hasNostrExtension, getPublicKey, signEvent, fetchNostrProfile } from '../services/nostr.js'
export default {
state: {
@ -121,6 +174,15 @@
password: '',
error: null,
loading: false,
hasNostr: false,
nostrLoading: false,
},
onMounted() {
// Detect after a tick — extensions inject window.nostr asynchronously
setTimeout(() => {
this.update({ hasNostr: hasNostrExtension() })
}, 100)
},
async handleSubmit(e) {
@ -129,7 +191,7 @@
try {
const data = await api.login({
email: this.state.email,
email: this.state.email.trim().toLowerCase(),
password: this.state.password,
})
this.props.cbLogin(data)
@ -137,6 +199,47 @@
this.update({ error: err.message, loading: false })
}
},
async handleNostrLogin() {
this.update({ nostrLoading: true, error: null })
try {
// 1. Get challenge
const { challenge } = await api.nostrChallenge()
// 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) so first login can prefill display name/avatar.
const profile = await fetchNostrProfile(pubkey)
// 4. Build unsigned event (NIP-07 compatible)
const unsignedEvent = {
kind: 27235,
content: nonce,
created_at: Math.floor(Date.now() / 1000),
tags: [['u', window.location.origin]],
}
// 5. Sign via extension
const signed = await signEvent(unsignedEvent)
// 6. Verify with server
const data = await api.nostrVerify({
signed_event: JSON.stringify(signed),
challenge,
profile_name: profile?.name || null,
profile_picture: profile?.picture || null,
})
this.props.cbLogin(data)
} catch (err) {
this.update({ error: err.message || 'Nostr login failed', nostrLoading: false })
}
},
}
</script>
</login-page>

View File

@ -1,6 +1,6 @@
<message-bubble>
<div class={'message ' + (props.message?.is_ai ? 'ai-message' : '') + (props.isOwn ? ' own-message' : '')}>
<div if={!props.isOwn} class="message-avatar-col">
<div class="message-avatar-col">
<div class={'message-avatar ' + (props.message?.is_ai ? 'ai-avatar' : '')}>
<svg if={props.message?.is_ai} width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="4" y="8" width="16" height="12" rx="2"/>
@ -9,22 +9,27 @@
<circle cx="9" cy="14" r="1.5" fill="currentColor"/>
<circle cx="15" cy="14" r="1.5" fill="currentColor"/>
</svg>
<template if={!props.message?.is_ai}>{props.message?.sender_name?.charAt(0).toUpperCase()}</template>
<img if={!props.message?.is_ai}
src={getMessageAvatar(props.message)}
alt={props.message?.sender_name}
width="32"
height="32"
class="avatar-img"
loading="lazy"
/>
</div>
</div>
<div class="message-body">
<div if={!props.isOwn} class="message-header">
<div class={'message-header ' + (props.isOwn ? 'own' : '')}>
<span class="sender-name">{props.message?.sender_name}</span>
<span class="message-time">{formatTime(props.message?.created_at)}</span>
</div>
<div if={props.isOwn} class="message-header own">
<span class="message-time">{formatTime(props.message?.created_at)}</span>
<span class="message-time" title={fullTimestamp(props.message?.created_at)}>{formatTime(props.message?.created_at)}</span>
<span if={props.message?.hash} class="message-hash" title={props.message.hash}>{props.message.hash.slice(0, 7)}</span>
</div>
<div if={hasToolResults()} class="tool-results-section">
<div each={tr in getToolResults()} class="tool-result-item">
<button class="tool-result-toggle" onclick={toggleToolResult}>
<span class="tool-result-icon">{tr.tool === 'brave_search' ? '🔍' : tr.tool === 'web_fetch' ? '🌐' : '⚙️'}</span>
<span class="tool-result-label">{tr.tool === 'brave_search' ? 'Search' : tr.tool === 'web_fetch' ? 'Fetched' : tr.tool}: {tr.input}</span>
<span class="tool-result-icon">{isSearchTool(tr.tool) ? '🔍' : tr.tool === 'web_fetch' ? '🌐' : '⚙️'}</span>
<span class="tool-result-label">{isSearchTool(tr.tool) ? 'Search' : tr.tool === 'web_fetch' ? 'Fetched' : tr.tool}: {tr.input}</span>
<span class="tool-result-arrow">▼</span>
</button>
<div class="tool-result-body collapsed">
@ -32,14 +37,24 @@
</div>
</div>
</div>
<div if={props.message?.image_url} class="message-image-wrap">
<img src={props.message.image_url} alt="Attached image" class="message-image" loading="lazy" onclick={openImageFullscreen} />
</div>
<div if={props.isStreaming} class="message-content streaming-content">{props.message?.content}<span class="streaming-cursor">▌</span></div>
<div if={!props.isStreaming} class="message-content markdown-content"></div>
<div if={props.message?.is_ai && props.message?.ai_meta} class="ai-stats-bar">
<button class="ai-stat-btn" onclick={copyFullMessage} title="Copy response">
<div if={!props.isStreaming} class="message-actions-bar">
<button class="msg-action-btn" onclick={copyFullMessage} title="Copy message">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
</svg>
</button>
<button if={props.message?.hash} class="msg-action-btn" onclick={copyMessageLink} title="Copy link to message">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
</svg>
</button>
<template if={props.message?.is_ai && props.message?.ai_meta}>
<span class="ai-stat-item model">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2a4 4 0 0 0-4 4v2H6a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V10a2 2 0 0 0-2-2h-2V6a4 4 0 0 0-4-4z"/></svg>
{formatModel(props.message.ai_meta.model)}
@ -53,6 +68,7 @@
<span class="ai-stat-item" title="Response time">
⏱ {(props.message.ai_meta.response_ms / 1000).toFixed(1)}s
</span>
</template>
</div>
</div>
</div>
@ -91,6 +107,14 @@
font-weight: 600;
font-size: var(--text-xs);
color: var(--text-secondary);
overflow: hidden;
}
.avatar-img {
width: 100%;
height: 100%;
border-radius: var(--radius-full);
object-fit: cover;
}
.ai-avatar {
@ -111,6 +135,7 @@
.message-header.own {
justify-content: flex-end;
flex-direction: row-reverse;
}
.sender-name {
@ -128,6 +153,20 @@
color: var(--text-muted);
}
.message-hash {
font-family: var(--font-mono, 'SF Mono', 'Fira Code', monospace);
font-size: 10px;
color: var(--text-muted);
opacity: 0.5;
cursor: default;
user-select: all;
transition: opacity var(--transition-fast);
}
.message-hash:hover {
opacity: 1;
}
.message-content {
padding: var(--space-sm) var(--space-md);
border-radius: var(--radius-lg);
@ -246,7 +285,7 @@
overflow-y: auto;
}
.ai-stats-bar {
.message-actions-bar {
display: flex;
align-items: center;
gap: var(--space-sm);
@ -255,9 +294,21 @@
font-size: 11px;
color: var(--text-muted);
flex-wrap: wrap;
opacity: 0;
transition: opacity var(--transition-fast);
}
.ai-stat-btn {
.message:hover .message-actions-bar,
.message-actions-bar:focus-within {
opacity: 1;
}
/* Always show for AI messages with stats */
.ai-message .message-actions-bar {
opacity: 1;
}
.msg-action-btn {
display: inline-flex;
align-items: center;
justify-content: center;
@ -268,12 +319,12 @@
line-height: 1;
}
.ai-stat-btn:hover {
.msg-action-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.ai-stat-btn.copied {
.msg-action-btn.copied {
color: var(--success);
}
@ -289,6 +340,27 @@
font-weight: 500;
}
.message-image-wrap {
margin-bottom: 4px;
padding: 4px;
border-radius: var(--radius-md);
overflow: hidden;
}
.message-image {
max-width: 320px;
max-height: 280px;
border-radius: var(--radius-md);
cursor: pointer;
display: block;
object-fit: contain;
transition: opacity 0.15s;
}
.message-image:hover {
opacity: 0.9;
}
.streaming-content {
white-space: pre-wrap;
word-wrap: break-word;
@ -308,8 +380,15 @@
<script>
import { renderMarkdown } from '../services/markdown.js'
import { avatarFromHash } from '../services/avatar.js'
export default {
/** Prefer custom avatar_url, fall back to Gravatar hash */
getMessageAvatar(msg) {
if (msg?.avatar_url) return msg.avatar_url
return avatarFromHash(msg?.avatar_hash, 32)
},
onMounted() {
this.renderContent()
},
@ -322,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) => {
@ -352,6 +432,14 @@
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
},
fullTimestamp(dateStr) {
if (!dateStr) return ''
const date = new Date(dateStr)
const human = date.toLocaleString([], { dateStyle: 'full', timeStyle: 'long' })
const unix = Math.floor(date.getTime() / 1000)
return `${human}\nUnix: ${unix}`
},
formatModel(model) {
if (!model) return 'unknown'
// "openai/gpt-4o" → "gpt-4o", "anthropic/claude-3.5-sonnet" → "claude-3.5-sonnet"
@ -375,10 +463,15 @@
return this.props.message?.ai_meta?.tool_results || []
},
isSearchTool(toolName) {
return toolName === 'web_search' || toolName === 'brave_search'
},
toggleToolResult(e) {
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')
@ -388,6 +481,11 @@
}
},
openImageFullscreen(e) {
const url = e.target.src
window.open(url, '_blank')
},
copyFullMessage(e) {
e.preventDefault()
e.stopPropagation()
@ -399,7 +497,27 @@
btn.title = 'Copied!'
setTimeout(() => {
btn.classList.remove('copied')
btn.title = 'Copy response'
btn.title = 'Copy message'
}, 2000)
})
},
copyMessageLink(e) {
e.preventDefault()
e.stopPropagation()
const hash = this.props.message?.hash
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(() => {
btn.classList.add('copied')
btn.title = 'Link copied!'
setTimeout(() => {
btn.classList.remove('copied')
btn.title = 'Copy link to message'
}, 2000)
})
},

View File

@ -0,0 +1,313 @@
<profile-page>
<div class="profile-overlay" onclick={handleOverlayClick}>
<div class="profile-card">
<div class="profile-header">
<h2>Profile</h2>
<button class="btn btn-ghost btn-icon" onclick={props.cbClose} title="Close">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<div class="avatar-section">
<div class="avatar-wrapper">
<img src={currentAvatar()} alt="Avatar" class="avatar-preview" width="96" height="96" />
<label class="avatar-edit-btn" title="Upload avatar">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/>
<circle cx="12" cy="13" r="4"/>
</svg>
<input type="file" accept="image/png,image/jpeg,image/gif,image/webp" onchange={handleAvatarUpload} class="hidden-input" />
</label>
</div>
<p if={state.avatarUploading} class="upload-status">Uploading...</p>
<button if={props.user?.avatar_url} class="btn btn-ghost btn-sm remove-avatar" onclick={handleRemoveAvatar}>
Remove custom avatar
</button>
</div>
<form onsubmit={handleSave}>
<div class="form-group">
<label for="displayName">Display Name</label>
<input
type="text"
id="displayName"
value={state.displayName}
oninput={e => update({ displayName: e.target.value })}
required
/>
</div>
<div class="form-group" if={!props.user?.nostr_pubkey}>
<label>Email</label>
<input type="email" value={props.user?.email} disabled class="input-disabled" />
<span class="form-hint">Email cannot be changed</span>
</div>
<div class="form-group" if={props.user?.nostr_pubkey}>
<label>Nostr Public Key</label>
<input type="text" value={npubDisplay()} disabled class="input-disabled input-mono" />
<span class="form-hint">Logged in via Nostr</span>
</div>
<p if={state.error} class="error-text">{state.error}</p>
<p if={state.success} class="success-text">{state.success}</p>
<div class="profile-actions">
<button type="button" class="btn btn-ghost" onclick={props.cbClose}>Cancel</button>
<button type="submit" class="btn btn-primary" disabled={state.saving}>
{state.saving ? 'Saving...' : 'Save Changes'}
</button>
</div>
</form>
</div>
</div>
<style>
.profile-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
animation: fadeIn 0.15s ease-out;
}
.profile-card {
width: 100%;
max-width: 440px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: var(--space-xl);
animation: slideUp 0.2s ease-out;
}
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes slideUp { from { transform: translateY(16px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
.profile-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-lg);
}
.profile-header h2 {
font-size: var(--text-lg);
font-weight: 600;
}
.avatar-section {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: var(--space-xl);
}
.avatar-wrapper {
position: relative;
width: 96px;
height: 96px;
}
.avatar-preview {
width: 96px;
height: 96px;
border-radius: var(--radius-full);
object-fit: cover;
border: 3px solid var(--border);
}
.avatar-edit-btn {
position: absolute;
bottom: 0;
right: 0;
width: 32px;
height: 32px;
border-radius: var(--radius-full);
background: var(--accent);
color: white;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background var(--transition-fast);
border: 2px solid var(--bg-secondary);
}
.avatar-edit-btn:hover {
background: var(--accent-hover);
}
.hidden-input {
display: none;
}
.upload-status {
margin-top: var(--space-sm);
font-size: var(--text-sm);
color: var(--text-secondary);
}
.remove-avatar {
margin-top: var(--space-sm);
font-size: var(--text-xs);
color: var(--text-muted);
}
.form-group {
margin-bottom: var(--space-md);
}
.form-group label {
display: block;
margin-bottom: var(--space-xs);
font-size: var(--text-sm);
color: var(--text-secondary);
}
.input-disabled {
opacity: 0.5;
cursor: not-allowed;
}
.input-mono {
font-family: var(--font-mono);
font-size: var(--text-xs);
}
.form-hint {
display: block;
margin-top: 4px;
font-size: var(--text-xs);
color: var(--text-muted);
}
.error-text {
color: var(--error);
font-size: var(--text-sm);
margin-bottom: var(--space-sm);
}
.success-text {
color: var(--success);
font-size: var(--text-sm);
margin-bottom: var(--space-sm);
}
.profile-actions {
display: flex;
justify-content: flex-end;
gap: var(--space-sm);
margin-top: var(--space-lg);
}
.btn-icon {
width: 36px;
height: 36px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-md);
}
.btn-sm {
padding: var(--space-xs) var(--space-md);
font-size: var(--text-sm);
}
</style>
<script>
import { api, saveAuth } from '../services/api.js'
import { getAvatarUrl } from '../services/avatar.js'
export default {
state: {
displayName: '',
saving: false,
avatarUploading: false,
error: null,
success: null,
},
onMounted() {
// Copy the current profile value into local form state so edits stay cancelable.
this.update({
displayName: this.props.user?.display_name || '',
})
},
npubDisplay() {
const hex = this.props.user?.nostr_pubkey
if (!hex) return ''
// Show truncated hex with npub prefix hint
return 'npub...' + hex.slice(-12)
},
currentAvatar() {
return getAvatarUrl(this.props.user, 96)
},
handleOverlayClick(e) {
if (e.target.classList.contains('profile-overlay')) {
this.props.cbClose()
}
},
async handleSave(e) {
e.preventDefault()
this.update({ saving: true, error: null, success: null })
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!' })
} catch (err) {
this.update({ saving: false, error: err.message })
}
},
async handleAvatarUpload(e) {
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
}
this.update({ avatarUploading: true, error: null, success: null })
try {
const data = await api.uploadAvatar(file)
saveAuth(data.token, data.user)
this.props.cbProfileUpdate(data.user)
this.update({ avatarUploading: false, success: 'Avatar updated!' })
} catch (err) {
this.update({ avatarUploading: false, error: err.message })
}
},
async handleRemoveAvatar() {
this.update({ error: null, success: null })
try {
const data = await api.deleteAvatar()
saveAuth(data.token, data.user)
this.props.cbProfileUpdate(data.user)
this.update({ success: 'Avatar removed' })
} catch (err) {
this.update({ error: err.message })
}
},
}
</script>
</profile-page>

View File

@ -51,6 +51,17 @@
</button>
</form>
<div if={state.hasNostr} class="nostr-divider">
<span>or</span>
</div>
<button if={state.hasNostr} class="btn btn-nostr btn-full" onclick={handleNostrLogin} disabled={state.nostrLoading}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 8px; vertical-align: -2px;">
<path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/>
</svg>
{state.nostrLoading ? 'Connecting...' : 'Sign up with Nostr'}
</button>
<p class="auth-footer">
Already have an account?
<a href="#" onclick={e => { e.preventDefault(); props.cbSwitch() }}>Sign in</a>
@ -117,6 +128,47 @@
margin-bottom: var(--space-sm);
}
.nostr-divider {
display: flex;
align-items: center;
gap: var(--space-md);
margin: var(--space-lg) 0;
color: var(--text-muted);
font-size: var(--text-sm);
}
.nostr-divider::before,
.nostr-divider::after {
content: '';
flex: 1;
height: 1px;
background: var(--border);
}
.btn-nostr {
background: #8B5CF6;
color: white;
border: none;
padding: var(--space-sm) var(--space-md);
border-radius: var(--radius-md);
font-size: var(--text-sm);
font-weight: 500;
cursor: pointer;
transition: background var(--transition-fast);
display: flex;
align-items: center;
justify-content: center;
}
.btn-nostr:hover:not(:disabled) {
background: #7C3AED;
}
.btn-nostr:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.auth-footer {
text-align: center;
margin-top: var(--space-lg);
@ -127,6 +179,7 @@
<script>
import { api } from '../services/api.js'
import { hasNostrExtension, getPublicKey, signEvent, fetchNostrProfile } from '../services/nostr.js'
export default {
state: {
@ -135,6 +188,14 @@
password: '',
error: null,
loading: false,
hasNostr: false,
nostrLoading: false,
},
onMounted() {
setTimeout(() => {
this.update({ hasNostr: hasNostrExtension() })
}, 100)
},
async handleSubmit(e) {
@ -143,15 +204,47 @@
try {
const data = await api.register({
email: this.state.email,
email: this.state.email.trim().toLowerCase(),
password: this.state.password,
display_name: this.state.display_name,
display_name: this.state.display_name.trim(),
})
this.props.cbRegister(data)
} catch (err) {
this.update({ error: err.message, loading: false })
}
},
async handleNostrLogin() {
this.update({ nostrLoading: true, error: null })
try {
const { challenge } = await api.nostrChallenge()
const nonce = JSON.parse(atob(challenge.split('.')[1])).nonce
const pubkey = await getPublicKey()
const profile = await fetchNostrProfile(pubkey)
const unsignedEvent = {
kind: 27235,
content: nonce,
created_at: Math.floor(Date.now() / 1000),
tags: [['u', window.location.origin]],
}
const signed = await signEvent(unsignedEvent)
const data = await api.nostrVerify({
signed_event: JSON.stringify(signed),
challenge,
profile_name: profile?.name || null,
profile_picture: profile?.picture || null,
})
// Nostr login auto-creates — use same callback as register
this.props.cbRegister(data)
} catch (err) {
this.update({ error: err.message || 'Nostr login failed', nostrLoading: false })
}
},
}
</script>
</register-page>

View File

@ -10,6 +10,7 @@ import InviteModal from './components/invite-modal.riot'
import DeleteRoomModal from './components/delete-room-modal.riot'
import ClearConfirmModal from './components/clear-confirm-modal.riot'
import MessageBubble from './components/message-bubble.riot'
import ProfilePage from './components/profile-page.riot'
// Register all components
register('login-page', LoginPage)
@ -21,6 +22,7 @@ register('invite-modal', InviteModal)
register('delete-room-modal', DeleteRoomModal)
register('clear-confirm-modal', ClearConfirmModal)
register('message-bubble', MessageBubble)
register('profile-page', ProfilePage)
// Mount the app
const mountApp = component(App)

View File

@ -1,15 +1,24 @@
const API_BASE = '/api'
// Global callback for 401 responses (set by app component to trigger auto-logout)
let onUnauthorized = null
export function setOnUnauthorized(callback) {
onUnauthorized = callback
}
function getToken() {
return localStorage.getItem('token')
}
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: {
@ -25,8 +34,15 @@ async function request(method, path, body) {
const res = await fetch(`${API_BASE}${path}`, opts)
if (!res.ok) {
// Auto-logout on 401 for any authenticated request (not login/register)
if (res.status === 401 && path !== '/auth/login' && path !== '/auth/register' && path !== '/auth/nostr/verify') {
if (onUnauthorized) onUnauthorized()
throw new Error('Session expired — please log in again')
}
const text = await res.text()
throw new Error(text || `HTTP ${res.status}`)
const err = new Error(text || `HTTP ${res.status}`)
err.status = res.status
throw err
}
if (res.status === 204 || res.headers.get('content-length') === '0') {
@ -42,11 +58,30 @@ export const api = {
login: (data) => request('POST', '/auth/login', data),
me: () => request('GET', '/auth/me'),
// Profile
updateProfile: (data) => request('PUT', '/auth/profile', data),
uploadAvatar: async (file) => {
const formData = new FormData()
formData.append('avatar', file)
const res = await fetch(`${API_BASE}/auth/avatar`, {
method: 'POST',
headers: authHeaders(),
body: formData,
})
if (!res.ok) {
const text = await res.text()
throw new Error(text || `HTTP ${res.status}`)
}
return res.json()
},
deleteAvatar: () => request('DELETE', '/auth/avatar'),
// Rooms
listRooms: () => request('GET', '/rooms'),
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}`)
@ -54,16 +89,39 @@ export const api = {
joinRoom: (roomId) => request('POST', `/rooms/${roomId}/join`),
deleteRoom: (roomId) => request('DELETE', `/rooms/${roomId}`),
clearRoom: (roomId) => request('POST', `/rooms/${roomId}/clear`),
resolveMessageHash: (hash) => request('GET', `/messages/hash/${hash}`),
// Models
listModels: () => request('GET', '/models'),
// Upload (chat images)
uploadChatImage: async (file) => {
const formData = new FormData()
formData.append('image', file)
const res = await fetch(`${API_BASE}/upload`, {
method: 'POST',
headers: authHeaders(),
body: formData,
})
if (!res.ok) {
const text = await res.text()
throw new Error(text || `HTTP ${res.status}`)
}
return res.json()
},
// Invites
createInvite: (data) => request('POST', '/invites', data),
acceptInvite: (token) => request('POST', `/invites/${token}/accept`),
inviteByNostr: (data) => request('POST', '/invites/nostr', data),
// Nostr auth
nostrChallenge: () => request('GET', '/auth/nostr/challenge'),
nostrVerify: (data) => request('POST', '/auth/nostr/verify', data),
}
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

@ -0,0 +1,133 @@
/**
* Avatar utilities.
* Supports custom uploaded avatars with Gravatar + "monsterid" fallback.
*/
/**
* Get the best avatar URL for a user object.
* Prefers custom avatar_url, falls back to Gravatar.
* @param {object} user - User object with optional avatar_url and email
* @param {number} [size=64] - Pixel size (for Gravatar fallback)
* @returns {string} Avatar URL
*/
export function getAvatarUrl(user, size = 64) {
if (user?.avatar_url) {
// External URLs (e.g. Nostr profile pictures) are used as-is
if (user.avatar_url.startsWith('http://') || user.avatar_url.startsWith('https://')) {
return user.avatar_url
}
return user.avatar_url
}
return avatarFromEmail(user?.email, size)
}
/**
* Get avatar URL from a pre-computed MD5 hash (used for messages).
* @param {string} hash - MD5 hash of the user's email (from server)
* @param {number} [size=64] - Pixel size
* @returns {string} Gravatar URL
*/
export function avatarFromHash(hash, size = 64) {
if (!hash) return `https://www.gravatar.com/avatar/?d=monsterid&s=${size}`
return `https://www.gravatar.com/avatar/${hash}?d=monsterid&s=${size}`
}
/**
* Get avatar URL from an email address (used for member list / sidebar).
* Computes MD5 client-side.
* @param {string} email - User's email
* @param {number} [size=64] - Pixel size
* @returns {string} Gravatar URL
*/
export function avatarFromEmail(email, size = 64) {
if (!email) return `https://www.gravatar.com/avatar/?d=monsterid&s=${size}`
const hash = md5(email.trim().toLowerCase())
return `https://www.gravatar.com/avatar/${hash}?d=monsterid&s=${size}`
}
// ── Compact MD5 implementation ──────────────────────────────────────────
// Based on Joseph Myers' implementation (public domain)
function md5(string) {
function md5cycle(x, k) {
let a = x[0], b = x[1], c = x[2], d = x[3]
a = ff(a, b, c, d, k[0], 7, -680876936); d = ff(d, a, b, c, k[1], 12, -389564586)
c = ff(c, d, a, b, k[2], 17, 606105819); b = ff(b, c, d, a, k[3], 22, -1044525330)
a = ff(a, b, c, d, k[4], 7, -176418897); d = ff(d, a, b, c, k[5], 12, 1200080426)
c = ff(c, d, a, b, k[6], 17, -1473231341); b = ff(b, c, d, a, k[7], 22, -45705983)
a = ff(a, b, c, d, k[8], 7, 1770035416); d = ff(d, a, b, c, k[9], 12, -1958414417)
c = ff(c, d, a, b, k[10], 17, -42063); b = ff(b, c, d, a, k[11], 22, -1990404162)
a = ff(a, b, c, d, k[12], 7, 1804603682); d = ff(d, a, b, c, k[13], 12, -40341101)
c = ff(c, d, a, b, k[14], 17, -1502002290); b = ff(b, c, d, a, k[15], 22, 1236535329)
a = gg(a, b, c, d, k[1], 5, -165796510); d = gg(d, a, b, c, k[6], 9, -1069501632)
c = gg(c, d, a, b, k[11], 14, 643717713); b = gg(b, c, d, a, k[0], 20, -373897302)
a = gg(a, b, c, d, k[5], 5, -701558691); d = gg(d, a, b, c, k[10], 9, 38016083)
c = gg(c, d, a, b, k[15], 14, -660478335); b = gg(b, c, d, a, k[4], 20, -405537848)
a = gg(a, b, c, d, k[9], 5, 568446438); d = gg(d, a, b, c, k[14], 9, -1019803690)
c = gg(c, d, a, b, k[3], 14, -187363961); b = gg(b, c, d, a, k[8], 20, 1163531501)
a = gg(a, b, c, d, k[13], 5, -1444681467); d = gg(d, a, b, c, k[2], 9, -51403784)
c = gg(c, d, a, b, k[7], 14, 1735328473); b = gg(b, c, d, a, k[12], 20, -1926607734)
a = hh(a, b, c, d, k[5], 4, -378558); d = hh(d, a, b, c, k[8], 11, -2022574463)
c = hh(c, d, a, b, k[11], 16, 1839030562); b = hh(b, c, d, a, k[14], 23, -35309556)
a = hh(a, b, c, d, k[1], 4, -1530992060); d = hh(d, a, b, c, k[4], 11, 1272893353)
c = hh(c, d, a, b, k[7], 16, -155497632); b = hh(b, c, d, a, k[10], 23, -1094730640)
a = hh(a, b, c, d, k[13], 4, 681279174); d = hh(d, a, b, c, k[0], 11, -358537222)
c = hh(c, d, a, b, k[3], 16, -722521979); b = hh(b, c, d, a, k[6], 23, 76029189)
a = hh(a, b, c, d, k[9], 4, -640364487); d = hh(d, a, b, c, k[12], 11, -421815835)
c = hh(c, d, a, b, k[15], 16, 530742520); b = hh(b, c, d, a, k[2], 23, -995338651)
a = ii(a, b, c, d, k[0], 6, -198630844); d = ii(d, a, b, c, k[7], 10, 1126891415)
c = ii(c, d, a, b, k[14], 15, -1416354905); b = ii(b, c, d, a, k[5], 21, -57434055)
a = ii(a, b, c, d, k[12], 6, 1700485571); d = ii(d, a, b, c, k[3], 10, -1894986606)
c = ii(c, d, a, b, k[10], 15, -1051523); b = ii(b, c, d, a, k[1], 21, -2054922799)
a = ii(a, b, c, d, k[8], 6, 1873313359); d = ii(d, a, b, c, k[15], 10, -30611744)
c = ii(c, d, a, b, k[6], 15, -1560198380); b = ii(b, c, d, a, k[13], 21, 1309151649)
a = ii(a, b, c, d, k[4], 6, -145523070); d = ii(d, a, b, c, k[11], 10, -1120210379)
c = ii(c, d, a, b, k[2], 15, 718787259); b = ii(b, c, d, a, k[9], 21, -343485551)
x[0] = add32(a, x[0]); x[1] = add32(b, x[1])
x[2] = add32(c, x[2]); x[3] = add32(d, x[3])
}
function cmn(q, a, b, x, s, t) {
a = add32(add32(a, q), add32(x, t))
return add32((a << s) | (a >>> (32 - s)), b)
}
function ff(a, b, c, d, x, s, t) { return cmn((b & c) | ((~b) & d), a, b, x, s, t) }
function gg(a, b, c, d, x, s, t) { return cmn((b & d) | (c & (~d)), a, b, x, s, t) }
function hh(a, b, c, d, x, s, t) { return cmn(b ^ c ^ d, a, b, x, s, t) }
function ii(a, b, c, d, x, s, t) { return cmn(c ^ (b | (~d)), a, b, x, s, t) }
function add32(a, b) { return (a + b) & 0xFFFFFFFF }
const n = string.length
let state = [1732584193, -271733879, -1732584194, 271733878]
let i
for (i = 64; i <= n; i += 64) {
md5cycle(state, md5blk(string.substring(i - 64, i)))
}
string = string.substring(i - 64)
const tail = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
for (i = 0; i < string.length; i++)
tail[i >> 2] |= string.charCodeAt(i) << ((i % 4) << 3)
tail[i >> 2] |= 0x80 << ((i % 4) << 3)
if (i > 55) {
md5cycle(state, tail)
for (i = 0; i < 16; i++) tail[i] = 0
}
tail[14] = n * 8
md5cycle(state, tail)
return hex(state)
function md5blk(s) {
const md5blks = []
for (let i = 0; i < 64; i += 4)
md5blks[i >> 2] = s.charCodeAt(i) + (s.charCodeAt(i + 1) << 8) +
(s.charCodeAt(i + 2) << 16) + (s.charCodeAt(i + 3) << 24)
return md5blks
}
function hex(x) {
const hexChars = '0123456789abcdef'
let s = ''
for (let i = 0; i < 4; i++)
for (let j = 0; j < 4; j++)
s += hexChars[(x[i] >> (j * 8 + 4)) & 0x0F] + hexChars[(x[i] >> (j * 8)) & 0x0F]
return s
}
}

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

@ -0,0 +1,69 @@
/**
* NIP-07 browser extension helpers + relay profile fetch
*/
export function hasNostrExtension() {
return typeof window !== 'undefined' && !!window.nostr
}
export async function getPublicKey() {
return window.nostr.getPublicKey()
}
export async function signEvent(event) {
return window.nostr.signEvent(event)
}
const RELAYS = [
'wss://relay.damus.io',
'wss://relay.nostr.band',
'wss://nos.lol',
]
/**
* Fetch a Nostr kind:0 profile from relays via WebSocket.
* Races multiple relays, returns first result.
* Returns { name, picture } or null on timeout/error.
*/
export function fetchNostrProfile(pubkeyHex, timeoutMs = 5000) {
return new Promise((resolve) => {
let resolved = false
const connections = []
const done = (result) => {
if (resolved) return
resolved = true
connections.forEach(ws => { try { ws.close() } catch {} })
resolve(result)
}
setTimeout(() => done(null), timeoutMs)
for (const relay of RELAYS) {
try {
const ws = new WebSocket(relay)
connections.push(ws)
ws.onopen = () => {
const subId = 'p_' + Math.random().toString(36).slice(2, 8)
ws.send(JSON.stringify(['REQ', subId, { kinds: [0], authors: [pubkeyHex], limit: 1 }]))
}
ws.onmessage = (msg) => {
try {
const data = JSON.parse(msg.data)
if (data[0] === 'EVENT' && data[2]?.kind === 0) {
const profile = JSON.parse(data[2].content)
done({
name: profile.name || profile.display_name || null,
picture: profile.picture || null,
})
}
} catch {}
}
ws.onerror = () => {}
} catch {}
}
})
}

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()
}
@ -17,6 +18,7 @@ class WebSocketManager {
if (this.ws && this.ws.readyState === WebSocket.OPEN) return
this.token = token
this._authFailed = false
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const host = window.location.host
@ -25,6 +27,7 @@ class WebSocketManager {
this.ws.onopen = () => {
console.log('[WS] Connected')
this.reconnectDelay = 1000
this._authFailed = false
// Re-subscribe to all rooms we were watching
for (const roomId of this.subscribedRooms) {
@ -46,8 +49,25 @@ class WebSocketManager {
}
this.ws.onclose = (event) => {
console.log('[WS] Disconnected', event.code)
console.log('[WS] Disconnected, code:', event.code)
this.emit('disconnected')
// Code 1008 = Policy Violation (server rejected auth)
// 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')
this.token = null
this.emit('auth_failed')
return
}
this._authFailed = true
}
if (this.token) {
this.scheduleReconnect()
}
@ -61,6 +81,7 @@ class WebSocketManager {
disconnect() {
this.token = null
this.subscribedRooms.clear()
this.listeners.clear()
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer)
this.reconnectTimer = null
@ -98,18 +119,21 @@ 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 })
}
sendMessage(roomId, content, mentions = []) {
sendMessage(roomId, content, mentions = [], imageUrl = null) {
console.log('[WS] Sending message to room:', roomId)
this.send({
const msg = {
type: 'send_message',
room_id: roomId,
content,
mentions,
})
}
if (imageUrl) msg.image_url = imageUrl
this.send(msg)
}
sendTyping(roomId) {

View File

@ -18,19 +18,26 @@ function riotPlugin() {
}
}
const apiPort = process.env.VITE_API_PORT || '3001'
const clientPort = parseInt(process.env.VITE_PORT || '3000')
export default defineConfig({
plugins: [riotPlugin()],
server: {
port: 3000,
port: clientPort,
proxy: {
'/api': {
target: 'http://localhost:3001',
target: `http://localhost:${apiPort}`,
changeOrigin: true,
},
'/ws': {
target: 'ws://localhost:3001',
target: `ws://localhost:${apiPort}`,
ws: true,
},
'/uploads': {
target: `http://localhost:${apiPort}`,
changeOrigin: true,
},
},
},
})

94
dev.sh Executable file
View File

@ -0,0 +1,94 @@
#!/usr/bin/env bash
# GroupChat2 Dev Script (macOS/Linux)
# Runs both server (Rust/Axum) and client (Vite) with unified console output.
# Uses separate ports and database from production so both can run simultaneously.
#
# Production: server :3001, DB chat.db
# Development: server :3002, Vite client :3003, DB chat-dev.db
#
# Ctrl+C stops both processes.
set -e
ROOT="$(cd "$(dirname "$0")" && pwd)"
# ─── Dev environment (separate from production) ─────────────────────
DEV_SERVER_PORT=3002
DEV_CLIENT_PORT=3003
DEV_DATABASE="chat-dev.db"
export DATABASE_URL="sqlite:${DEV_DATABASE}?mode=rwc"
export BIND_ADDR="0.0.0.0:${DEV_SERVER_PORT}"
export VITE_PORT="${DEV_CLIENT_PORT}"
export VITE_API_PORT="${DEV_SERVER_PORT}"
export RUST_LOG="${RUST_LOG:-info}"
# Colors
MAGENTA='\033[0;35m'
YELLOW='\033[0;33m'
CYAN='\033[0;36m'
RED='\033[0;31m'
GRAY='\033[0;90m'
NC='\033[0m'
# Load .env for API keys (shared with production)
if [ -f "$ROOT/server/.env" ]; then
set -a
source "$ROOT/server/.env"
set +a
# Override DB and port with dev values (in case .env sets them)
export DATABASE_URL="sqlite:${DEV_DATABASE}?mode=rwc"
export BIND_ADDR="0.0.0.0:${DEV_SERVER_PORT}"
fi
# Install client deps if needed
if [ ! -d "$ROOT/client/node_modules" ]; then
echo -e "${CYAN}[dev] Installing client dependencies...${NC}"
(cd "$ROOT/client" && npm install)
fi
echo ""
echo -e " ${MAGENTA}GroupChat2 Dev Server${NC}"
echo -e " ${YELLOW}Server: http://localhost:${DEV_SERVER_PORT}${NC} ${GRAY}(DB: ${DEV_DATABASE})${NC}"
echo -e " ${CYAN}Client: http://localhost:${DEV_CLIENT_PORT}${NC}"
echo -e " ${GRAY}Press Ctrl+C to stop both${NC}"
echo ""
# Track child PIDs for cleanup
SERVER_PID=""
CLIENT_PID=""
cleanup() {
echo ""
echo -e "${RED}[dev] Shutting down...${NC}"
# Kill background jobs
[ -n "$SERVER_PID" ] && kill "$SERVER_PID" 2>/dev/null
[ -n "$CLIENT_PID" ] && kill "$CLIENT_PID" 2>/dev/null
# Kill any lingering processes on dev ports
lsof -ti:${DEV_CLIENT_PORT} 2>/dev/null | xargs kill -9 2>/dev/null || true
lsof -ti:${DEV_SERVER_PORT} 2>/dev/null | xargs kill -9 2>/dev/null || true
echo -e "${RED}[dev] Stopped.${NC}"
exit 0
}
trap cleanup INT TERM
# Start server (Rust/Axum) on dev port with dev database
(cd "$ROOT/server" && cargo run 2>&1 | while IFS= read -r line; do
[ -n "$line" ] && echo -e "${YELLOW}[server]${NC} $line"
done) &
SERVER_PID=$!
# Start client (Vite) on dev port, proxying to dev server
(cd "$ROOT/client" && npm run dev 2>&1 | while IFS= read -r line; do
[ -n "$line" ] && echo -e "${CYAN}[client]${NC} $line"
done) &
CLIENT_PID=$!
# Wait for either to exit
wait -n "$SERVER_PID" "$CLIENT_PID" 2>/dev/null
echo -e "${RED}[dev] A process exited unexpectedly.${NC}"
cleanup

162
prod.sh Executable file
View File

@ -0,0 +1,162 @@
#!/usr/bin/env bash
# GroupChat2 Production Build & Run Script
# Builds the client, compiles the server in release mode, and runs it.
# The Rust server serves the static client files directly — no Vite needed.
#
# Usage:
# ./prod.sh # Build + run
# ./prod.sh build # Build only (no run)
# ./prod.sh run # Run only (skip build, assumes already built)
set -e
ROOT="$(cd "$(dirname "$0")" && pwd)"
# Colors
MAGENTA='\033[0;35m'
YELLOW='\033[0;33m'
CYAN='\033[0;36m'
RED='\033[0;31m'
GREEN='\033[0;32m'
GRAY='\033[0;90m'
NC='\033[0m'
MODE="${1:-all}" # all | build | run
# ─── Preflight checks ───────────────────────────────────────────────
check_env() {
if [ ! -f "$ROOT/server/.env" ]; then
echo -e "${RED}[prod] Missing server/.env file!${NC}"
echo -e "${GRAY} Copy server/.env.example and fill in your keys:${NC}"
echo -e "${GRAY} cp server/.env.example server/.env${NC}"
exit 1
fi
# Source .env to validate required vars
set -a
source "$ROOT/server/.env"
set +a
SEARCH_PROVIDER="${SEARCH_PROVIDER:-tavily}"
if [ -z "$OPENROUTER_API_KEY" ] || [ "$OPENROUTER_API_KEY" = "your-openrouter-api-key-here" ]; then
echo -e "${RED}[prod] OPENROUTER_API_KEY not set in server/.env${NC}"
exit 1
fi
if [ "$SEARCH_PROVIDER" = "tavily" ]; then
if [ -z "$TAVILY_API_KEY" ] || [ "$TAVILY_API_KEY" = "tvly-your-key-here" ]; then
echo -e "${RED}[prod] TAVILY_API_KEY not set in server/.env${NC}"
exit 1
fi
elif [ "$SEARCH_PROVIDER" = "brave" ]; then
if [ -z "$BRAVE_API_KEY" ] || [ "$BRAVE_API_KEY" = "your-brave-api-key-here" ]; then
echo -e "${RED}[prod] BRAVE_API_KEY not set in server/.env${NC}"
exit 1
fi
else
echo -e "${RED}[prod] SEARCH_PROVIDER must be 'tavily' or 'brave'${NC}"
exit 1
fi
if [ -z "$JWT_SECRET" ] || [ "$JWT_SECRET" = "dev-secret-change-me" ]; then
echo -e "${YELLOW}[prod] WARNING: Using default JWT_SECRET — set a real secret for production!${NC}"
fi
}
# ─── Build ───────────────────────────────────────────────────────────
build() {
echo -e "${MAGENTA}[prod] Building GroupChat2 for production...${NC}"
echo ""
# 1. Install client deps if needed
if [ ! -d "$ROOT/client/node_modules" ]; then
echo -e "${CYAN}[prod] Installing client dependencies...${NC}"
(cd "$ROOT/client" && npm install)
fi
# 2. Build client (Vite → dist/)
echo -e "${CYAN}[prod] Building client...${NC}"
(cd "$ROOT/client" && npm run build)
echo -e "${GREEN}[prod] Client built → client/dist/${NC}"
# 3. Build server in release mode
echo -e "${YELLOW}[prod] Building server (release)...${NC}"
(cd "$ROOT/server" && cargo build --release)
echo -e "${GREEN}[prod] Server built → server/target/release/groupchat-server${NC}"
echo ""
echo -e "${GREEN}[prod] Build complete!${NC}"
}
# ─── Run ─────────────────────────────────────────────────────────────
run() {
check_env
# Verify build artifacts exist
if [ ! -f "$ROOT/server/target/release/groupchat-server" ]; then
echo -e "${RED}[prod] Server binary not found. Run './prod.sh build' first.${NC}"
exit 1
fi
if [ ! -d "$ROOT/client/dist" ]; then
echo -e "${RED}[prod] Client dist not found. Run './prod.sh build' first.${NC}"
exit 1
fi
BIND_ADDR="${BIND_ADDR:-0.0.0.0:3001}"
echo ""
echo -e " ${MAGENTA}GroupChat2 Production${NC}"
echo -e " ${GREEN}Domain: https://gchat.tud.ink${NC}"
echo -e " ${YELLOW}Bind: ${BIND_ADDR}${NC}"
echo -e " ${GRAY}Press Ctrl+C to stop${NC}"
echo ""
# Source .env so the server binary picks up the vars
set -a
source "$ROOT/server/.env"
set +a
export STATIC_DIR="$ROOT/client/dist"
export BIND_ADDR
export RUST_LOG="${RUST_LOG:-info}"
cleanup() {
echo ""
echo -e "${RED}[prod] Shutting down...${NC}"
kill "$SERVER_PID" 2>/dev/null || true
lsof -ti:3001 2>/dev/null | xargs kill -9 2>/dev/null || true
echo -e "${RED}[prod] Stopped.${NC}"
exit 0
}
trap cleanup INT TERM
# Run the release binary
"$ROOT/server/target/release/groupchat-server" &
SERVER_PID=$!
wait "$SERVER_PID"
}
# ─── Main ────────────────────────────────────────────────────────────
case "$MODE" in
build)
build
;;
run)
run
;;
all|"")
build
run
;;
*)
echo "Usage: $0 [build|run]"
echo " (no args) Build and run"
echo " build Build only"
echo " run Run only (must build first)"
exit 1
;;
esac

View File

@ -10,3 +10,15 @@ JWT_SECRET=change-me-to-a-random-secret
# OpenRouter API
OPENROUTER_API_KEY=sk-or-v1-your-key-here
# Search provider: tavily or brave
SEARCH_PROVIDER=tavily
# Tavily Search API
TAVILY_API_KEY=tvly-your-key-here
# Brave Search API (optional unless SEARCH_PROVIDER=brave)
BRAVE_API_KEY=your-brave-api-key-here
# Production: path to built client files (default: ../client/dist)
# STATIC_DIR=../client/dist

269
server/Cargo.lock generated
View File

@ -8,6 +8,16 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "aead"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
dependencies = [
"crypto-common",
"generic-array",
]
[[package]]
name = "ahash"
version = "0.8.12"
@ -78,6 +88,12 @@ dependencies = [
"password-hash",
]
[[package]]
name = "arrayvec"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[package]]
name = "async-compression"
version = "0.4.41"
@ -143,6 +159,7 @@ dependencies = [
"matchit",
"memchr",
"mime",
"multer",
"percent-encoding",
"pin-project-lite",
"rustversion",
@ -234,6 +251,40 @@ version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]]
name = "bech32"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f"
[[package]]
name = "bip39"
version = "2.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc"
dependencies = [
"bitcoin_hashes",
"serde",
"unicode-normalization",
]
[[package]]
name = "bitcoin-io"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2dee39a0ee5b4095224a0cfc6bf4cc1baf0f9624b96b367e53b66d974e51d953"
[[package]]
name = "bitcoin_hashes"
version = "0.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26ec84b80c482df901772e931a9a681e26a1b9ee2302edeff23cb30328745c8b"
dependencies = [
"bitcoin-io",
"hex-conservative",
"serde",
]
[[package]]
name = "bitflags"
version = "2.11.0"
@ -261,6 +312,15 @@ dependencies = [
"generic-array",
]
[[package]]
name = "block-padding"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93"
dependencies = [
"generic-array",
]
[[package]]
name = "brotli"
version = "8.0.2"
@ -300,6 +360,15 @@ version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
[[package]]
name = "cbc"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6"
dependencies = [
"cipher",
]
[[package]]
name = "cc"
version = "1.2.56"
@ -316,6 +385,30 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "chacha20"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures",
]
[[package]]
name = "chacha20poly1305"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35"
dependencies = [
"aead",
"chacha20",
"cipher",
"poly1305",
"zeroize",
]
[[package]]
name = "chrono"
version = "0.4.44"
@ -330,6 +423,17 @@ dependencies = [
"windows-link",
]
[[package]]
name = "cipher"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common",
"inout",
"zeroize",
]
[[package]]
name = "compression-codecs"
version = "0.4.37"
@ -435,6 +539,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"rand_core",
"typenum",
]
@ -480,9 +585,9 @@ dependencies = [
[[package]]
name = "deranged"
version = "0.5.8"
version = "0.3.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4"
dependencies = [
"powerfmt",
]
@ -851,15 +956,19 @@ dependencies = [
"async-trait",
"axum",
"axum-extra",
"base64 0.22.1",
"chrono",
"dotenvy",
"futures",
"jsonwebtoken",
"md-5",
"nostr",
"rand",
"reqwest",
"scraper",
"serde",
"serde_json",
"sha2",
"sqlx",
"tokio",
"tower 0.4.13",
@ -967,6 +1076,15 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hex-conservative"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fda06d18ac606267c40c04e41b9947729bf8b9efe74bd4e82b61a5f26a510b9f"
dependencies = [
"arrayvec",
]
[[package]]
name = "hkdf"
version = "0.12.4"
@ -1281,6 +1399,28 @@ dependencies = [
"serde_core",
]
[[package]]
name = "inout"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"block-padding",
"generic-array",
]
[[package]]
name = "instant"
version = "0.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222"
dependencies = [
"cfg-if",
"js-sys",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "ipnet"
version = "2.12.0"
@ -1560,6 +1700,30 @@ dependencies = [
"minimal-lexical",
]
[[package]]
name = "nostr"
version = "0.44.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3aa5e3b6a278ed061835fe1ee293b71641e6bf8b401cfe4e1834bbf4ef0a34e1"
dependencies = [
"base64 0.22.1",
"bech32",
"bip39",
"bitcoin_hashes",
"cbc",
"chacha20",
"chacha20poly1305",
"getrandom 0.2.17",
"hex",
"instant",
"scrypt",
"secp256k1",
"serde",
"serde_json",
"unicode-normalization",
"url",
]
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
@ -1597,9 +1761,9 @@ dependencies = [
[[package]]
name = "num-conv"
version = "0.2.0"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050"
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
[[package]]
name = "num-integer"
@ -1637,6 +1801,12 @@ version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]]
name = "opaque-debug"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "openssl"
version = "0.10.75"
@ -1721,6 +1891,16 @@ version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
name = "pbkdf2"
version = "0.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2"
dependencies = [
"digest",
"hmac",
]
[[package]]
name = "pem"
version = "3.0.6"
@ -1843,6 +2023,17 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
[[package]]
name = "poly1305"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf"
dependencies = [
"cpufeatures",
"opaque-debug",
"universal-hash",
]
[[package]]
name = "potential_utf"
version = "0.1.4"
@ -2113,6 +2304,15 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "salsa20"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213"
dependencies = [
"cipher",
]
[[package]]
name = "schannel"
version = "0.1.28"
@ -2143,6 +2343,38 @@ dependencies = [
"tendril",
]
[[package]]
name = "scrypt"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f"
dependencies = [
"password-hash",
"pbkdf2",
"salsa20",
"sha2",
]
[[package]]
name = "secp256k1"
version = "0.29.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113"
dependencies = [
"rand",
"secp256k1-sys",
"serde",
]
[[package]]
name = "secp256k1-sys"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4387882333d3aa8cb20530a17c69a3752e97837832f34f6dccc760e715001d9"
dependencies = [
"cc",
]
[[package]]
name = "security-framework"
version = "3.7.0"
@ -2331,9 +2563,9 @@ checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
[[package]]
name = "simple_asn1"
version = "0.6.4"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d"
checksum = "297f631f50729c8c99b84667867963997ec0b50f32b2a7dbcab828ef0541e8bb"
dependencies = [
"num-bigint",
"num-traits",
@ -2786,30 +3018,30 @@ dependencies = [
[[package]]
name = "time"
version = "0.3.47"
version = "0.3.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
checksum = "35e7868883861bd0e56d9ac6efcaaca0d6d5d82a2a7ec8209ff492c07cf37b21"
dependencies = [
"deranged",
"itoa",
"num-conv",
"powerfmt",
"serde_core",
"serde",
"time-core",
"time-macros",
]
[[package]]
name = "time-core"
version = "0.1.8"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3"
[[package]]
name = "time-macros"
version = "0.2.27"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215"
checksum = "2834e6017e3e5e4b9834939793b282bc03b37a3336245fa820e35e233e2a85de"
dependencies = [
"num-conv",
"time-core",
@ -3160,6 +3392,16 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e"
[[package]]
name = "universal-hash"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
dependencies = [
"crypto-common",
"subtle",
]
[[package]]
name = "untrusted"
version = "0.9.0"
@ -3176,6 +3418,7 @@ dependencies = [
"idna",
"percent-encoding",
"serde",
"serde_derive",
]
[[package]]

View File

@ -4,7 +4,7 @@ version = "0.1.0"
edition = "2021"
[dependencies]
axum = { version = "0.7", features = ["ws", "macros"] }
axum = { version = "0.7", features = ["ws", "macros", "multipart"] }
axum-extra = { version = "0.9", features = ["typed-header"] }
tokio = { version = "1", features = ["full"] }
tower = "0.4"
@ -24,3 +24,7 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
rand = "0.8"
async-trait = "0.1"
scraper = "0.22"
md-5 = "0.10"
sha2 = "0.10"
base64 = "0.22"
nostr = { version = "0.44", default-features = false, features = ["std"] }

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

View File

@ -0,0 +1,2 @@
-- Add avatar_url column to users table for custom avatar uploads
ALTER TABLE users ADD COLUMN avatar_url TEXT;

View File

@ -0,0 +1 @@
ALTER TABLE messages ADD COLUMN image_url TEXT;

View File

@ -0,0 +1,5 @@
-- Add SHA-256 integrity hash column to messages
ALTER TABLE messages ADD COLUMN hash TEXT;
-- Backfill hashes for existing messages is done in Rust (see main.rs)
-- because SQLite doesn't have a built-in SHA-256 function.

View File

@ -0,0 +1,2 @@
ALTER TABLE users ADD COLUMN nostr_pubkey TEXT;
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_nostr_pubkey ON users(nostr_pubkey);

View File

@ -1,24 +1,36 @@
use axum::{extract::State, http::StatusCode, Json};
use argon2::{
password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
Argon2,
};
use axum::{extract::State, http::StatusCode, Json};
use std::sync::Arc;
use uuid::Uuid;
use crate::{
middleware::auth::{create_token, AuthUser},
models::{AuthResponse, LoginRequest, RegisterRequest, UserPublic},
models::{self, AuthResponse, LoginRequest, RegisterRequest, UserPublic},
AppState,
};
/// Create a new password-based account and immediately return a JWT.
pub async fn register(
State(state): State<Arc<AppState>>,
Json(body): Json<RegisterRequest>,
) -> Result<Json<AuthResponse>, (StatusCode, String)> {
// Check if email already exists
let existing = sqlx::query_scalar::<_, String>("SELECT id FROM users WHERE email = ?")
.bind(&body.email)
// Normalize email: trim whitespace and lowercase for consistent matching
let email = body.email.trim().to_lowercase();
let display_name = body.display_name.trim().to_string();
if email.is_empty() {
return Err((StatusCode::BAD_REQUEST, "Email is required".into()));
}
if display_name.is_empty() {
return Err((StatusCode::BAD_REQUEST, "Display name is required".into()));
}
// Check if email already exists (case-insensitive for safety with legacy data)
let existing = sqlx::query_scalar::<_, String>("SELECT id FROM users WHERE LOWER(email) = ?")
.bind(&email)
.fetch_optional(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
@ -36,43 +48,49 @@ pub async fn register(
sqlx::query("INSERT INTO users (id, email, display_name, password_hash) VALUES (?, ?, ?, ?)")
.bind(&user_id)
.bind(&body.email)
.bind(&body.display_name)
.bind(&email)
.bind(&display_name)
.bind(&password_hash)
.execute(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let token = create_token(&user_id, &body.email, &body.display_name, &state.jwt_secret)
let token = create_token(&user_id, &email, &display_name, &state.jwt_secret)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(AuthResponse {
token,
user: UserPublic {
id: user_id,
email: body.email,
display_name: body.display_name,
email: models::public_email(&email),
display_name,
avatar_url: None,
nostr_pubkey: None,
},
}))
}
/// Authenticate an existing password-based account and return a fresh JWT.
pub async fn login(
State(state): State<Arc<AppState>>,
Json(body): Json<LoginRequest>,
) -> Result<Json<AuthResponse>, (StatusCode, String)> {
let user = sqlx::query_as::<_, (String, String, String, String)>(
"SELECT id, email, display_name, password_hash FROM users WHERE email = ?",
// Normalize email: trim whitespace and lowercase for case-insensitive matching
let email = body.email.trim().to_lowercase();
let user = sqlx::query_as::<_, (String, String, String, String, Option<String>)>(
"SELECT id, email, display_name, password_hash, avatar_url FROM users WHERE LOWER(email) = ?",
)
.bind(&body.email)
.bind(&email)
.fetch_optional(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::UNAUTHORIZED, "Invalid credentials".into()))?;
let (user_id, email, display_name, hash) = user;
let (user_id, email, display_name, hash, avatar_url) = user;
let parsed_hash = PasswordHash::new(&hash)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let parsed_hash =
PasswordHash::new(&hash).map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Argon2::default()
.verify_password(body.password.as_bytes(), &parsed_hash)
@ -85,16 +103,33 @@ pub async fn login(
token,
user: UserPublic {
id: user_id,
email,
email: models::public_email(&email),
display_name,
avatar_url,
nostr_pubkey: None,
},
}))
}
pub async fn me(auth: AuthUser) -> Json<UserPublic> {
Json(UserPublic {
/// Return the caller's current public profile information.
pub async fn me(
auth: AuthUser,
State(state): State<Arc<AppState>>,
) -> Result<Json<UserPublic>, (StatusCode, String)> {
let row = sqlx::query_as::<_, (Option<String>, Option<String>)>(
"SELECT avatar_url, nostr_pubkey FROM users WHERE id = ?",
)
.bind(&auth.user_id)
.fetch_optional(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.unwrap_or((None, None));
Ok(Json(UserPublic {
id: auth.user_id,
email: auth.email,
email: models::public_email(&auth.email),
display_name: auth.display_name,
})
avatar_url: row.0,
nostr_pubkey: row.1,
}))
}

View File

@ -9,10 +9,11 @@ use uuid::Uuid;
use crate::{
middleware::auth::AuthUser,
models::CreateInviteRequest,
models::{CreateInviteRequest, NostrInviteRequest},
AppState,
};
/// Response payload for a newly created invite link.
#[derive(serde::Serialize)]
pub struct InviteResponse {
pub id: String,
@ -20,6 +21,7 @@ pub struct InviteResponse {
pub invite_url: String,
}
/// Create a one-time invite token for a room member to share.
pub async fn create_invite(
State(state): State<Arc<AppState>>,
auth: AuthUser,
@ -46,7 +48,9 @@ pub async fn create_invite(
.map(char::from)
.collect();
sqlx::query("INSERT INTO invites (id, room_id, invited_by, email, token) VALUES (?, ?, ?, ?, ?)")
sqlx::query(
"INSERT INTO invites (id, room_id, invited_by, email, token) VALUES (?, ?, ?, ?, ?)",
)
.bind(&invite_id)
.bind(&body.room_id)
.bind(&auth.user_id)
@ -63,11 +67,13 @@ pub async fn create_invite(
}))
}
/// Response payload returned after consuming an invite.
#[derive(serde::Serialize)]
pub struct AcceptInviteResponse {
pub room_id: String,
}
/// Consume an invite token and add the caller to the room.
pub async fn accept_invite(
State(state): State<Arc<AppState>>,
auth: AuthUser,
@ -89,9 +95,8 @@ pub async fn accept_invite(
}
// Verify room is not deleted
let room_active = sqlx::query_scalar::<_, String>(
"SELECT id FROM rooms WHERE id = ? AND deleted_at IS NULL",
)
let room_active =
sqlx::query_scalar::<_, String>("SELECT id FROM rooms WHERE id = ? AND deleted_at IS NULL")
.bind(&room_id)
.fetch_optional(&state.db)
.await
@ -118,3 +123,80 @@ pub async fn accept_invite(
Ok(Json(AcceptInviteResponse { room_id }))
}
/// Result of a Nostr-based room invite attempt.
#[derive(serde::Serialize)]
pub struct NostrInviteResponse {
pub status: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
}
/// Add a user to a room by their Nostr public key if they already have an account.
pub async fn invite_by_nostr(
State(state): State<Arc<AppState>>,
auth: AuthUser,
Json(body): Json<NostrInviteRequest>,
) -> Result<Json<NostrInviteResponse>, (StatusCode, String)> {
// Normalize pubkey: if it starts with "npub", decode bech32
let pubkey_hex = if body.nostr_pubkey.starts_with("npub") {
nostr::prelude::PublicKey::parse(&body.nostr_pubkey)
.map(|pk| pk.to_hex())
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid npub format".to_string()))?
} else {
// Validate it's valid hex
if body.nostr_pubkey.len() != 64
|| !body.nostr_pubkey.chars().all(|c| c.is_ascii_hexdigit())
{
return Err((
StatusCode::BAD_REQUEST,
"Invalid pubkey: must be 64-char hex or npub".to_string(),
));
}
body.nostr_pubkey.clone()
};
// Verify inviter is a member of the room
let is_member = sqlx::query_scalar::<_, String>(
"SELECT user_id FROM room_members WHERE room_id = ? AND user_id = ?",
)
.bind(&body.room_id)
.bind(&auth.user_id)
.fetch_optional(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if is_member.is_none() {
return Err((StatusCode::FORBIDDEN, "Not a member of this room".into()));
}
// Lookup user by nostr_pubkey
let target_user = sqlx::query_as::<_, (String, String)>(
"SELECT id, display_name FROM users WHERE nostr_pubkey = ?",
)
.bind(&pubkey_hex)
.fetch_optional(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
match target_user {
Some((user_id, display_name)) => {
// Add to room
sqlx::query("INSERT OR IGNORE INTO room_members (room_id, user_id) VALUES (?, ?)")
.bind(&body.room_id)
.bind(&user_id)
.execute(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(NostrInviteResponse {
status: "added".to_string(),
display_name: Some(display_name),
}))
}
None => Ok(Json(NostrInviteResponse {
status: "not_found".to_string(),
display_name: None,
})),
}
}

View File

@ -1,5 +1,13 @@
//! HTTP and WebSocket entry points for the server.
//!
//! Each submodule exposes route handlers that Axum wires into the router in
//! `main.rs`.
pub mod auth;
pub mod invites;
pub mod models;
pub mod nostr_auth;
pub mod profile;
pub mod rooms;
pub mod upload;
pub mod ws;

View File

@ -1,19 +1,16 @@
use axum::{
extract::State,
http::StatusCode,
Json,
};
use axum::{extract::State, http::StatusCode, Json};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tokio::sync::OnceCell;
use std::time::{Duration, Instant};
use tokio::sync::Mutex;
use tokio::sync::OnceCell;
use crate::AppState;
/// Cached model list with expiry.
static MODEL_CACHE: OnceCell<Mutex<CachedModels>> = OnceCell::const_new();
/// Process-wide cache for the OpenRouter model catalog.
struct CachedModels {
models: Vec<ModelInfo>,
fetched_at: Instant,
@ -21,6 +18,7 @@ struct CachedModels {
const CACHE_TTL: Duration = Duration::from_secs(60 * 30); // 30 minutes
/// Model metadata exposed to the client for room creation and model selection.
#[derive(Debug, Clone, Serialize)]
pub struct ModelInfo {
pub id: String,
@ -28,6 +26,7 @@ pub struct ModelInfo {
pub context_length: Option<u64>,
pub pricing_prompt: Option<String>,
pub pricing_completion: Option<String>,
pub supports_vision: bool,
}
#[derive(Debug, Deserialize)]
@ -41,6 +40,7 @@ struct OpenRouterModel {
name: String,
context_length: Option<u64>,
pricing: Option<OpenRouterPricing>,
architecture: Option<OpenRouterArchitecture>,
}
#[derive(Debug, Deserialize)]
@ -49,6 +49,15 @@ struct OpenRouterPricing {
completion: Option<String>,
}
#[derive(Debug, Deserialize)]
struct OpenRouterArchitecture {
input_modalities: Option<Vec<String>>,
}
/// Fetch the model catalog directly from OpenRouter.
///
/// The result is normalized into the smaller `ModelInfo` shape that the client
/// UI needs.
async fn fetch_models(api_key: &str) -> Result<Vec<ModelInfo>, String> {
let client = reqwest::Client::new();
@ -75,12 +84,19 @@ async fn fetch_models(api_key: &str) -> Result<Vec<ModelInfo>, String> {
.into_iter()
.map(|m| {
let pricing = m.pricing.as_ref();
let supports_vision = m
.architecture
.as_ref()
.and_then(|a| a.input_modalities.as_ref())
.map(|mods| mods.iter().any(|m| m == "image"))
.unwrap_or(false);
ModelInfo {
id: m.id,
name: m.name,
context_length: m.context_length,
pricing_prompt: pricing.and_then(|p| p.prompt.clone()),
pricing_completion: pricing.and_then(|p| p.completion.clone()),
supports_vision,
}
})
.collect();
@ -89,6 +105,7 @@ async fn fetch_models(api_key: &str) -> Result<Vec<ModelInfo>, String> {
Ok(models)
}
/// Return the cached OpenRouter model list, refreshing it when the cache expires.
pub async fn list_models(
State(state): State<Arc<AppState>>,
) -> Result<Json<Vec<ModelInfo>>, (StatusCode, String)> {

View File

@ -0,0 +1,182 @@
use axum::{extract::State, http::StatusCode, Json};
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
use nostr::prelude::*;
use std::sync::Arc;
use uuid::Uuid;
use crate::{
middleware::auth::create_token,
models::{AuthResponse, NostrChallengeResponse, NostrVerifyRequest, UserPublic},
AppState,
};
/// Claims embedded in the short-lived challenge token used during Nostr login.
#[derive(Debug, serde::Serialize, serde::Deserialize)]
struct ChallengeClaims {
pub nonce: String,
pub exp: usize,
}
/// GET /api/auth/nostr/challenge — return a short-lived JWT containing a random nonce
pub async fn challenge(
State(state): State<Arc<AppState>>,
) -> Result<Json<NostrChallengeResponse>, (StatusCode, String)> {
// Generate 32 random bytes as hex nonce
let mut nonce_bytes = [0u8; 32];
use rand::RngCore;
rand::thread_rng().fill_bytes(&mut nonce_bytes);
let nonce = hex::encode(&nonce_bytes);
let exp = (chrono::Utc::now().timestamp() + 120) as usize; // 2 minutes
let claims = ChallengeClaims { nonce, exp };
let token = encode(
&Header::default(),
&claims,
&EncodingKey::from_secret(state.jwt_secret.as_bytes()),
)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(NostrChallengeResponse { challenge: token }))
}
/// Simple hex encoder (avoid adding the `hex` crate just for this)
mod hex {
/// Convert raw bytes into a lowercase hexadecimal string.
pub fn encode(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{:02x}", b)).collect()
}
}
/// POST /api/auth/nostr/verify — verify signed event, create/login user
pub async fn verify(
State(state): State<Arc<AppState>>,
Json(body): Json<NostrVerifyRequest>,
) -> Result<Json<AuthResponse>, (StatusCode, String)> {
// 1. Decode challenge JWT, verify not expired, extract nonce
let challenge_data = decode::<ChallengeClaims>(
&body.challenge,
&DecodingKey::from_secret(state.jwt_secret.as_bytes()),
&Validation::default(),
)
.map_err(|_| {
(
StatusCode::BAD_REQUEST,
"Invalid or expired challenge".to_string(),
)
})?;
let nonce = &challenge_data.claims.nonce;
// 2. Deserialize signed_event as nostr::Event
let event: Event = serde_json::from_str(&body.signed_event).map_err(|e| {
(
StatusCode::BAD_REQUEST,
format!("Invalid event JSON: {}", e),
)
})?;
// 3. Verify Schnorr signature
if !event.verify_signature() {
return Err((
StatusCode::UNAUTHORIZED,
"Invalid event signature".to_string(),
));
}
// 4. Verify event.content == nonce
if event.content.as_str() != nonce.as_str() {
return Err((StatusCode::BAD_REQUEST, "Nonce mismatch".to_string()));
}
// 5. Verify event.created_at within 5 minutes
let now = chrono::Utc::now().timestamp() as u64;
let event_ts = event.created_at.as_secs();
if now.abs_diff(event_ts) > 300 {
return Err((
StatusCode::BAD_REQUEST,
"Event timestamp too far off".to_string(),
));
}
// 6. Extract pubkey hex
let pubkey_hex = event.pubkey.to_hex();
// 7. Lookup user by nostr_pubkey
let existing = sqlx::query_as::<_, (String, String, String, Option<String>)>(
"SELECT id, email, display_name, avatar_url FROM users WHERE nostr_pubkey = ?",
)
.bind(&pubkey_hex)
.fetch_optional(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let (user_id, email, display_name, avatar_url) = if let Some(user) = existing {
// Update avatar if provided and user doesn't have a custom one
if let Some(ref pic) = body.profile_picture {
if user.3.is_none() || user.3.as_deref() == Some("") {
let _ = sqlx::query("UPDATE users SET avatar_url = ? WHERE id = ?")
.bind(pic)
.bind(&user.0)
.execute(&state.db)
.await;
(user.0, user.1, user.2, Some(pic.clone()))
} else {
user
}
} else {
user
}
} else {
// Create new user
let user_id = Uuid::new_v4().to_string();
let sentinel_email = format!("nostr:{}", &pubkey_hex[..16]);
let display_name = body
.profile_name
.clone()
.filter(|n| !n.trim().is_empty())
.unwrap_or_else(|| {
let npub = PublicKey::from_hex(&pubkey_hex)
.map(|pk| pk.to_bech32().unwrap_or_default())
.unwrap_or_default();
if npub.len() > 8 {
format!("npub...{}", &npub[npub.len() - 8..])
} else {
format!("nostr-{}", &pubkey_hex[..8])
}
});
let avatar_url = body.profile_picture.clone();
sqlx::query(
"INSERT INTO users (id, email, display_name, password_hash, nostr_pubkey, avatar_url) VALUES (?, ?, ?, ?, ?, ?)",
)
.bind(&user_id)
.bind(&sentinel_email)
.bind(&display_name)
.bind("")
.bind(&pubkey_hex)
.bind(&avatar_url)
.execute(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
(user_id, sentinel_email, display_name, avatar_url)
};
// 8. Issue JWT, return AuthResponse
let token = create_token(&user_id, &email, &display_name, &state.jwt_secret)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(AuthResponse {
token,
user: UserPublic {
id: user_id,
email: crate::models::public_email(&email),
display_name,
avatar_url,
nostr_pubkey: Some(pubkey_hex),
},
}))
}

View File

@ -0,0 +1,203 @@
use axum::{
extract::{Multipart, State},
http::StatusCode,
Json,
};
use std::sync::Arc;
use crate::{
middleware::auth::{create_token, AuthUser},
models::{self, AuthResponse, UserPublic},
AppState,
};
/// Request body for profile updates.
#[derive(Debug, serde::Deserialize)]
pub struct UpdateProfileRequest {
pub display_name: Option<String>,
}
/// Update the user's display name. Returns a new token + user.
pub async fn update_profile(
auth: AuthUser,
State(state): State<Arc<AppState>>,
Json(body): Json<UpdateProfileRequest>,
) -> Result<Json<AuthResponse>, (StatusCode, String)> {
let display_name = body.display_name.unwrap_or(auth.display_name.clone());
if display_name.trim().is_empty() {
return Err((
StatusCode::BAD_REQUEST,
"Display name cannot be empty".into(),
));
}
sqlx::query("UPDATE users SET display_name = ? WHERE id = ?")
.bind(&display_name)
.bind(&auth.user_id)
.execute(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// Fetch the updated avatar_url
let avatar_url: Option<String> =
sqlx::query_scalar("SELECT avatar_url FROM users WHERE id = ?")
.bind(&auth.user_id)
.fetch_optional(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.flatten();
// Issue a new token with the updated display name
let token = create_token(&auth.user_id, &auth.email, &display_name, &state.jwt_secret)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(AuthResponse {
token,
user: UserPublic {
id: auth.user_id,
email: models::public_email(&auth.email),
display_name,
avatar_url,
nostr_pubkey: None,
},
}))
}
/// Upload a custom avatar image. Saves to uploads/avatars/<user_id>.ext
pub async fn upload_avatar(
auth: AuthUser,
State(state): State<Arc<AppState>>,
mut multipart: Multipart,
) -> Result<Json<AuthResponse>, (StatusCode, String)> {
let field = multipart
.next_field()
.await
.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?
.ok_or((StatusCode::BAD_REQUEST, "No file uploaded".into()))?;
let content_type = field
.content_type()
.unwrap_or("application/octet-stream")
.to_string();
// Only allow images
let ext = match content_type.as_str() {
"image/png" => "png",
"image/jpeg" | "image/jpg" => "jpg",
"image/gif" => "gif",
"image/webp" => "webp",
_ => {
return Err((
StatusCode::BAD_REQUEST,
"Only PNG, JPG, GIF, and WebP images are allowed".into(),
))
}
};
let data = field
.bytes()
.await
.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
// Limit to 2MB
if data.len() > 2 * 1024 * 1024 {
return Err((StatusCode::BAD_REQUEST, "Avatar must be under 2MB".into()));
}
// Save to uploads/avatars/
let upload_dir = std::path::Path::new("uploads/avatars");
tokio::fs::create_dir_all(upload_dir)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// Remove any old avatar files for this user
if let Ok(mut entries) = tokio::fs::read_dir(upload_dir).await {
while let Ok(Some(entry)) = entries.next_entry().await {
let name = entry.file_name().to_string_lossy().to_string();
if name.starts_with(&auth.user_id) {
let _ = tokio::fs::remove_file(entry.path()).await;
}
}
}
let filename = format!("{}.{}", auth.user_id, ext);
let filepath = upload_dir.join(&filename);
tokio::fs::write(&filepath, &data)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let avatar_url = format!("/uploads/avatars/{}", filename);
// Update DB
sqlx::query("UPDATE users SET avatar_url = ? WHERE id = ?")
.bind(&avatar_url)
.bind(&auth.user_id)
.execute(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// Issue new token
let token = create_token(
&auth.user_id,
&auth.email,
&auth.display_name,
&state.jwt_secret,
)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(AuthResponse {
token,
user: UserPublic {
id: auth.user_id,
email: models::public_email(&auth.email),
display_name: auth.display_name,
avatar_url: Some(avatar_url),
nostr_pubkey: None,
},
}))
}
/// Remove the user's custom avatar, reverting to Gravatar.
pub async fn delete_avatar(
auth: AuthUser,
State(state): State<Arc<AppState>>,
) -> Result<Json<AuthResponse>, (StatusCode, String)> {
// Remove files
let upload_dir = std::path::Path::new("uploads/avatars");
if let Ok(mut entries) = tokio::fs::read_dir(upload_dir).await {
while let Ok(Some(entry)) = entries.next_entry().await {
let name = entry.file_name().to_string_lossy().to_string();
if name.starts_with(&auth.user_id) {
let _ = tokio::fs::remove_file(entry.path()).await;
}
}
}
// Clear in DB
sqlx::query("UPDATE users SET avatar_url = NULL WHERE id = ?")
.bind(&auth.user_id)
.execute(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let token = create_token(
&auth.user_id,
&auth.email,
&auth.display_name,
&state.jwt_secret,
)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(AuthResponse {
token,
user: UserPublic {
id: auth.user_id,
email: models::public_email(&auth.email),
display_name: auth.display_name,
avatar_url: None,
nostr_pubkey: None,
},
}))
}

View File

@ -8,10 +8,13 @@ use uuid::Uuid;
use crate::{
middleware::auth::AuthUser,
models::{CreateRoomRequest, Message, MessagePayload, PaginationParams, Room, RoomResponse, UserPublic},
models::{
self, CreateRoomRequest, MessagePayload, PaginationParams, Room, RoomResponse, UserPublic,
},
AppState,
};
/// Create a room, persist it, and add the creator as the first member.
pub async fn create_room(
State(state): State<Arc<AppState>>,
auth: AuthUser,
@ -52,12 +55,15 @@ pub async fn create_room(
created_at: chrono::Utc::now().to_rfc3339(),
members: vec![UserPublic {
id: auth.user_id,
email: auth.email,
email: models::public_email(&auth.email),
display_name: auth.display_name,
avatar_url: None,
nostr_pubkey: None,
}],
}))
}
/// List all active rooms the caller belongs to, including current room members.
pub async fn list_rooms(
State(state): State<Arc<AppState>>,
auth: AuthUser,
@ -72,8 +78,8 @@ pub async fn list_rooms(
let mut result = Vec::new();
for room in rooms {
let members = sqlx::query_as::<_, (String, String, String)>(
"SELECT u.id, u.email, u.display_name FROM users u JOIN room_members rm ON u.id = rm.user_id WHERE rm.room_id = ?",
let members = sqlx::query_as::<_, (String, String, String, Option<String>, Option<String>)>(
"SELECT u.id, u.email, u.display_name, u.avatar_url, u.nostr_pubkey FROM users u JOIN room_members rm ON u.id = rm.user_id WHERE rm.room_id = ?",
)
.bind(&room.id)
.fetch_all(&state.db)
@ -91,11 +97,15 @@ pub async fn list_rooms(
created_at: room.created_at,
members: members
.into_iter()
.map(|(id, email, display_name)| UserPublic {
.map(
|(id, email, display_name, avatar_url, nostr_pubkey)| UserPublic {
id,
email,
email: models::public_email(&email),
display_name,
})
avatar_url,
nostr_pubkey,
},
)
.collect(),
});
}
@ -103,6 +113,7 @@ pub async fn list_rooms(
Ok(Json(result))
}
/// Return details for a single room after verifying the caller is a member.
pub async fn get_room(
State(state): State<Arc<AppState>>,
auth: AuthUser,
@ -129,8 +140,8 @@ pub async fn get_room(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::NOT_FOUND, "Room not found".into()))?;
let members = sqlx::query_as::<_, (String, String, String)>(
"SELECT u.id, u.email, u.display_name FROM users u JOIN room_members rm ON u.id = rm.user_id WHERE rm.room_id = ?",
let members = sqlx::query_as::<_, (String, String, String, Option<String>, Option<String>)>(
"SELECT u.id, u.email, u.display_name, u.avatar_url, u.nostr_pubkey FROM users u JOIN room_members rm ON u.id = rm.user_id WHERE rm.room_id = ?",
)
.bind(&room_id)
.fetch_all(&state.db)
@ -148,15 +159,20 @@ pub async fn get_room(
created_at: room.created_at,
members: members
.into_iter()
.map(|(id, email, display_name)| UserPublic {
.map(
|(id, email, display_name, avatar_url, nostr_pubkey)| UserPublic {
id,
email,
email: models::public_email(&email),
display_name,
})
avatar_url,
nostr_pubkey,
},
)
.collect(),
}))
}
/// Return paginated message history for a room the caller can access.
pub async fn get_messages(
State(state): State<Arc<AppState>>,
auth: AuthUser,
@ -177,9 +193,12 @@ pub async fn get_messages(
return Err((StatusCode::FORBIDDEN, "Not a member of this room".into()));
}
let messages = if let Some(before) = &params.before {
sqlx::query_as::<_, Message>(
"SELECT * FROM messages WHERE room_id = ? AND created_at < ? ORDER BY created_at DESC LIMIT ?",
// Query messages with user email + avatar_url via LEFT JOIN
let rows = if let Some(before) = &params.before {
sqlx::query_as::<_, (String, String, String, String, String, String, bool, String, Option<String>, Option<String>, Option<String>, Option<String>, Option<String>)>(
"SELECT m.id, m.room_id, m.sender_id, m.sender_name, m.content, m.mentions, m.is_ai, m.created_at, m.ai_meta, m.image_url, u.email, u.avatar_url, m.hash \
FROM messages m LEFT JOIN users u ON m.sender_id = u.id \
WHERE m.room_id = ? AND m.created_at < ? ORDER BY m.created_at DESC LIMIT ?",
)
.bind(&room_id)
.bind(before)
@ -187,8 +206,10 @@ pub async fn get_messages(
.fetch_all(&state.db)
.await
} else {
sqlx::query_as::<_, Message>(
"SELECT * FROM messages WHERE room_id = ? ORDER BY created_at DESC LIMIT ?",
sqlx::query_as::<_, (String, String, String, String, String, String, bool, String, Option<String>, Option<String>, Option<String>, Option<String>, Option<String>)>(
"SELECT m.id, m.room_id, m.sender_id, m.sender_name, m.content, m.mentions, m.is_ai, m.created_at, m.ai_meta, m.image_url, u.email, u.avatar_url, m.hash \
FROM messages m LEFT JOIN users u ON m.sender_id = u.id \
WHERE m.room_id = ? ORDER BY m.created_at DESC LIMIT ?",
)
.bind(&room_id)
.bind(params.limit)
@ -197,37 +218,94 @@ pub async fn get_messages(
}
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let payloads: Vec<MessagePayload> = messages
// The SQL query reads newest-first for efficient pagination, but clients
// render chat oldest-to-newest, so reverse the rows before serializing.
let payloads: Vec<MessagePayload> = rows
.into_iter()
.rev()
.map(|m| {
let ai_meta = m.ai_meta
.map(
|(
id,
room_id,
sender_id,
sender_name,
content,
mentions,
is_ai,
created_at,
ai_meta_str,
image_url,
email,
avatar_url,
hash,
)| {
let ai_meta = ai_meta_str
.as_deref()
.and_then(|s| serde_json::from_str::<crate::models::AiMeta>(s).ok());
let avatar_hash = email
.map(|e| crate::models::gravatar_hash(&e))
.unwrap_or_default();
MessagePayload {
id: m.id,
room_id: m.room_id,
sender_id: m.sender_id,
sender_name: m.sender_name,
content: m.content,
mentions: serde_json::from_str(&m.mentions).unwrap_or_default(),
is_ai: m.is_ai,
created_at: m.created_at,
id,
room_id,
sender_id,
sender_name,
content,
mentions: serde_json::from_str(&mentions).unwrap_or_default(),
is_ai,
created_at,
ai_meta,
avatar_hash,
avatar_url,
image_url,
hash,
}
})
},
)
.collect();
Ok(Json(payloads))
}
/// Resolve a stable message hash into the room that contains it.
pub async fn resolve_message_hash(
State(state): State<Arc<AppState>>,
auth: AuthUser,
Path(hash): Path<String>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
// Find the message by hash
let row = sqlx::query_as::<_, (String,)>(
"SELECT m.room_id FROM messages m \
JOIN room_members rm ON rm.room_id = m.room_id AND rm.user_id = ? \
JOIN rooms r ON r.id = m.room_id AND r.deleted_at IS NULL \
WHERE m.hash = ? LIMIT 1",
)
.bind(&auth.user_id)
.bind(&hash)
.fetch_optional(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
match row {
Some((room_id,)) => Ok(Json(
serde_json::json!({ "room_id": room_id, "hash": hash }),
)),
None => Err((
StatusCode::NOT_FOUND,
"Message not found or no access".into(),
)),
}
}
/// Add the caller to a room directly when they already know its ID.
pub async fn join_room(
State(state): State<Arc<AppState>>,
auth: AuthUser,
Path(room_id): Path<String>,
) -> Result<StatusCode, (StatusCode, String)> {
// Check room exists
let room_exists = sqlx::query_scalar::<_, String>("SELECT id FROM rooms WHERE id = ? AND deleted_at IS NULL")
let room_exists =
sqlx::query_scalar::<_, String>("SELECT id FROM rooms WHERE id = ? AND deleted_at IS NULL")
.bind(&room_id)
.fetch_optional(&state.db)
.await
@ -247,6 +325,7 @@ pub async fn join_room(
Ok(StatusCode::OK)
}
/// Soft-delete a room and broadcast the deletion event to connected members.
pub async fn delete_room(
State(state): State<Arc<AppState>>,
auth: AuthUser,
@ -261,7 +340,10 @@ pub async fn delete_room(
.ok_or((StatusCode::NOT_FOUND, "Room not found".into()))?;
if room.created_by != auth.user_id {
return Err((StatusCode::FORBIDDEN, "Only the room creator can delete this room".into()));
return Err((
StatusCode::FORBIDDEN,
"Only the room creator can delete this room".into(),
));
}
// Soft-delete
@ -282,6 +364,7 @@ pub async fn delete_room(
Ok(StatusCode::OK)
}
/// Permanently remove all messages from a room without deleting the room itself.
pub async fn clear_room(
State(state): State<Arc<AppState>>,
auth: AuthUser,
@ -296,7 +379,10 @@ pub async fn clear_room(
.ok_or((StatusCode::NOT_FOUND, "Room not found".into()))?;
if room.created_by != auth.user_id {
return Err((StatusCode::FORBIDDEN, "Only the room creator can clear messages".into()));
return Err((
StatusCode::FORBIDDEN,
"Only the room creator can clear messages".into(),
));
}
// Hard-delete all messages

View File

@ -0,0 +1,64 @@
use axum::{extract::Multipart, http::StatusCode, Json};
use serde::Serialize;
use uuid::Uuid;
use crate::middleware::auth::AuthUser;
/// Response returned after a chat image upload succeeds.
#[derive(Serialize)]
pub struct UploadResponse {
pub url: String,
}
/// Accept a multipart chat image upload and store it under `uploads/chat-images`.
pub async fn upload_chat_image(
_auth: AuthUser,
mut multipart: Multipart,
) -> Result<Json<UploadResponse>, (StatusCode, String)> {
while let Some(field) = multipart
.next_field()
.await
.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?
{
let content_type = field.content_type().unwrap_or("").to_string();
let ext = match content_type.as_str() {
"image/png" => "png",
"image/jpeg" => "jpg",
"image/gif" => "gif",
"image/webp" => "webp",
_ => {
return Err((
StatusCode::BAD_REQUEST,
"Only PNG, JPG, GIF, WebP images are allowed".into(),
))
}
};
let data = field
.bytes()
.await
.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
if data.len() > 5 * 1024 * 1024 {
return Err((StatusCode::BAD_REQUEST, "Image must be under 5MB".into()));
}
let dir = "uploads/chat-images";
tokio::fs::create_dir_all(dir)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let filename = format!("{}.{}", Uuid::new_v4(), ext);
let path = format!("{}/{}", dir, filename);
tokio::fs::write(&path, &data)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
return Ok(Json(UploadResponse {
url: format!("/uploads/chat-images/{}", filename),
}));
}
Err((StatusCode::BAD_REQUEST, "No image provided".into()))
}

View File

@ -1,3 +1,9 @@
//! WebSocket workflow for live chat delivery and AI responses.
//!
//! This module does two jobs:
//! - fan out database-backed room events to subscribed browser sockets
//! - turn incoming user chat messages into stored messages and optional AI replies
use axum::{
extract::{
ws::{Message, WebSocket},
@ -12,7 +18,7 @@ use uuid::Uuid;
use crate::{
middleware::auth::decode_token,
models::{BroadcastEvent, MessagePayload, WsClientMessage, WsServerMessage},
services::{brave, fetch, openrouter},
services::{fetch, openrouter, search},
AppState,
};
@ -24,6 +30,7 @@ pub struct WsQuery {
token: String,
}
/// Upgrade an authenticated request into a WebSocket connection.
pub async fn ws_handler(
ws: WebSocketUpgrade,
State(state): State<Arc<AppState>>,
@ -37,10 +44,19 @@ pub async fn ws_handler(
}
};
ws.on_upgrade(move |socket| handle_socket(socket, state, claims.sub, claims.display_name))
ws.on_upgrade(move |socket| {
handle_socket(socket, state, claims.sub, claims.display_name, claims.email)
})
}
async fn handle_socket(socket: WebSocket, state: Arc<AppState>, user_id: String, display_name: String) {
/// Drive a single WebSocket connection until either the send or receive side ends.
async fn handle_socket(
socket: WebSocket,
state: Arc<AppState>,
user_id: String,
display_name: String,
email: String,
) {
let (mut ws_tx, mut ws_rx) = socket.split();
let mut broadcast_rx = state.tx.subscribe();
@ -50,7 +66,8 @@ async fn handle_socket(socket: WebSocket, state: Arc<AppState>, user_id: String,
let rooms_clone = subscribed_rooms.clone();
// Task: forward broadcast events to this client
// Task 1: forward room events from the shared broadcast channel into this
// specific socket, but only for rooms the browser subscribed to.
let mut send_task = tokio::spawn(async move {
loop {
match broadcast_rx.recv().await {
@ -78,9 +95,11 @@ async fn handle_socket(socket: WebSocket, state: Arc<AppState>, user_id: String,
let state_clone = state.clone();
let user_id_clone = user_id.clone();
let display_name_clone = display_name.clone();
let email_clone = email.clone();
let rooms_clone2 = subscribed_rooms.clone();
// Task: receive messages from client
// Task 2: receive commands from the browser and translate them into
// database writes, broadcasts, or AI work.
let mut recv_task = tokio::spawn(async move {
while let Some(Ok(msg)) = ws_rx.next().await {
let text = match msg {
@ -121,15 +140,18 @@ async fn handle_socket(socket: WebSocket, state: Arc<AppState>, user_id: String,
room_id,
content,
mentions,
image_url,
} => {
tracing::info!("User {} sending message to room {}", user_id_clone, room_id);
handle_send_message(
&state_clone,
&user_id_clone,
&display_name_clone,
&email_clone,
&room_id,
&content,
&mentions,
image_url.as_deref(),
)
.await;
}
@ -137,7 +159,7 @@ async fn handle_socket(socket: WebSocket, state: Arc<AppState>, user_id: String,
}
});
// Wait for either task to finish, then abort the other
// If either half of the connection ends, stop the companion task too.
tokio::select! {
_ = &mut send_task => recv_task.abort(),
_ = &mut recv_task => send_task.abort(),
@ -146,21 +168,27 @@ async fn handle_socket(socket: WebSocket, state: Arc<AppState>, user_id: String,
tracing::info!("WebSocket disconnected: {}", user_id);
}
/// Persist a user message, broadcast it, and optionally generate an AI reply.
async fn handle_send_message(
state: &Arc<AppState>,
user_id: &str,
display_name: &str,
email: &str,
room_id: &str,
content: &str,
mentions: &[String],
image_url: Option<&str>,
) {
let msg_id = Uuid::new_v4().to_string();
let mentions_json = serde_json::to_string(mentions).unwrap_or_else(|_| "[]".to_string());
let now = chrono::Utc::now().to_rfc3339();
// Store in database
// Compute integrity hash from timestamp + content
let hash = crate::models::message_hash(&now, content);
// Store in database (with image_url)
let _ = sqlx::query(
"INSERT INTO messages (id, room_id, sender_id, sender_name, content, mentions, is_ai, created_at) VALUES (?, ?, ?, ?, ?, ?, 0, ?)",
"INSERT INTO messages (id, room_id, sender_id, sender_name, content, mentions, is_ai, created_at, image_url, hash) VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?)",
)
.bind(&msg_id)
.bind(room_id)
@ -169,9 +197,21 @@ async fn handle_send_message(
.bind(content)
.bind(&mentions_json)
.bind(&now)
.bind(image_url)
.bind(&hash)
.execute(&state.db)
.await;
// Look up the sender's custom avatar (if any) for the message payload
let avatar_url: Option<String> =
sqlx::query_scalar("SELECT avatar_url FROM users WHERE id = ?")
.bind(user_id)
.fetch_optional(&state.db)
.await
.ok()
.flatten()
.flatten();
// Broadcast human message
let payload = MessagePayload {
id: msg_id,
@ -183,16 +223,19 @@ async fn handle_send_message(
is_ai: false,
created_at: now,
ai_meta: None,
avatar_hash: crate::models::gravatar_hash(email),
avatar_url,
image_url: image_url.map(String::from),
hash: Some(hash),
};
let _ = state.tx.send(BroadcastEvent {
room_id: room_id.to_string(),
message: WsServerMessage::NewMessage {
message: payload,
},
message: WsServerMessage::NewMessage { message: payload },
});
// Check if AI should respond
// The AI only replies when explicitly mentioned or when the room is set to
// auto-reply to every message.
let ai_user_id = "ai-assistant";
let should_respond = mentions.contains(&ai_user_id.to_string());
@ -221,16 +264,26 @@ async fn handle_send_message(
},
});
// Fetch recent history
let recent_messages = sqlx::query_as::<_, (String, String, bool)>(
"SELECT sender_name, content, is_ai FROM messages WHERE room_id = ? ORDER BY created_at DESC LIMIT 50",
// Fetch recent history (including image_url for multimodal support)
let recent_messages = sqlx::query_as::<_, (String, String, bool, Option<String>)>(
"SELECT sender_name, content, is_ai, image_url FROM messages WHERE room_id = ? ORDER BY created_at DESC LIMIT 50",
)
.bind(room_id)
.fetch_all(&state.db)
.await
.unwrap_or_default();
let history: Vec<(String, String, bool)> = recent_messages.into_iter().rev().collect();
// OpenRouter accepts image inputs as data URLs, so local uploads need to be
// loaded from disk and encoded before they are sent upstream.
let mut history: Vec<(String, String, bool, Option<String>)> = Vec::new();
for (sender_name, msg_content, is_ai, msg_image_url) in recent_messages.into_iter().rev() {
let image_data_url = match &msg_image_url {
Some(url) if !is_ai => encode_image_as_data_url(url).await,
_ => None,
};
history.push((sender_name, msg_content, is_ai, image_data_url));
}
let mut chat_history = openrouter::build_chat_history(&system_prompt, &history);
// Build tools for AI
@ -239,7 +292,8 @@ async fn handle_send_message(
// Pre-generate AI message ID so we can reference it in stream chunks
let ai_msg_id = Uuid::new_v4().to_string();
// Call OpenRouter with tool loop — uses streaming for all rounds
// Run the AI in a loop because the model may first request tools, then need
// follow-up rounds after those tool results are added to history.
let mut total_prompt_tokens: u32 = 0;
let mut total_completion_tokens: u32 = 0;
let mut total_response_ms: u64 = 0;
@ -280,16 +334,24 @@ async fn handle_send_message(
tracing::info!(
"AI requesting tool calls (round {}): {:?}",
round + 1,
assistant_msg.tool_calls.as_ref().map(|tc| tc.iter().map(|t| &t.function.name).collect::<Vec<_>>())
assistant_msg
.tool_calls
.as_ref()
.map(|tc| tc.iter().map(|t| &t.function.name).collect::<Vec<_>>())
);
// Add the assistant's tool-call message to history
// Preserve the assistant tool-call message so the next round
// has the same context the model produced.
let tool_calls = assistant_msg.tool_calls.clone().unwrap_or_default();
chat_history.push(assistant_msg);
// Execute each tool call and add results
// Tool results are fed back into the conversation as
// synthetic `tool` messages, matching the upstream API.
for tool_call in &tool_calls {
let tool_input = extract_tool_input(&tool_call.function.name, &tool_call.function.arguments);
let tool_input = extract_tool_input(
&tool_call.function.name,
&tool_call.function.arguments,
);
// Broadcast real-time tool usage event
let _ = state.tx.send(BroadcastEvent {
@ -304,7 +366,9 @@ async fn handle_send_message(
let tool_result = execute_tool(
&tool_call.function.name,
&tool_call.function.arguments,
&state.brave_api_key,
state.search_provider,
state.tavily_api_key.as_deref(),
state.brave_api_key.as_deref(),
)
.await;
@ -322,12 +386,12 @@ async fn handle_send_message(
chat_history.push(openrouter::ChatMessage {
role: "tool".into(),
content: Some(tool_result),
content: Some(openrouter::Content::Text(tool_result)),
tool_calls: None,
tool_call_id: Some(tool_call.id.clone()),
});
}
// Continue to next round (tool loop)
// Ask the model to continue now that tool output exists.
continue 'tool_loop;
}
openrouter::StreamEvent::Done(stats) => {
@ -347,9 +411,12 @@ async fn handle_send_message(
}
}
// If we exhausted all rounds without a text response, note it
// Guardrail: if the model never produced final prose, store a clear fallback
// instead of leaving the client waiting indefinitely.
if ai_response.is_empty() && !had_error {
ai_response = "*I used several tools but couldn't formulate a final response. Please try again.*".to_string();
ai_response =
"*I used several tools but couldn't formulate a final response. Please try again.*"
.to_string();
}
// Signal stream end so client can finalize rendering
@ -384,8 +451,11 @@ async fn handle_send_message(
// Serialize ai_meta for database storage
let ai_meta_json = ai_meta.as_ref().and_then(|m| serde_json::to_string(m).ok());
// Compute integrity hash from timestamp + content
let ai_hash = crate::models::message_hash(&ai_now, &ai_response);
let _ = sqlx::query(
"INSERT INTO messages (id, room_id, sender_id, sender_name, content, mentions, is_ai, created_at, ai_meta) VALUES (?, ?, ?, ?, ?, '[]', 1, ?, ?)",
"INSERT INTO messages (id, room_id, sender_id, sender_name, content, mentions, is_ai, created_at, ai_meta, hash) VALUES (?, ?, ?, ?, ?, '[]', 1, ?, ?, ?)",
)
.bind(&ai_msg_id)
.bind(room_id)
@ -394,6 +464,7 @@ async fn handle_send_message(
.bind(&ai_response)
.bind(&ai_now)
.bind(&ai_meta_json)
.bind(&ai_hash)
.execute(&state.db)
.await;
@ -408,6 +479,10 @@ async fn handle_send_message(
is_ai: true,
created_at: ai_now,
ai_meta,
avatar_hash: String::new(),
avatar_url: None,
image_url: None,
hash: Some(ai_hash),
};
let _ = state.tx.send(BroadcastEvent {
@ -418,20 +493,49 @@ async fn handle_send_message(
});
}
/// Read a local image file and encode it as a base64 data URL for the OpenRouter API.
async fn encode_image_as_data_url(url: &str) -> Option<String> {
// url is like "/uploads/chat-images/uuid.png"
let file_path = url.strip_prefix('/').unwrap_or(url);
let data = tokio::fs::read(file_path).await.ok()?;
let mime = if file_path.ends_with(".png") {
"image/png"
} else if file_path.ends_with(".jpg") || file_path.ends_with(".jpeg") {
"image/jpeg"
} else if file_path.ends_with(".gif") {
"image/gif"
} else if file_path.ends_with(".webp") {
"image/webp"
} else {
"application/octet-stream"
};
use base64::Engine;
let b64 = base64::engine::general_purpose::STANDARD.encode(&data);
Some(format!("data:{};base64,{}", mime, b64))
}
/// Extract a human-readable input string from tool arguments (for UI display).
fn extract_tool_input(tool_name: &str, arguments: &str) -> String {
let args: serde_json::Value = serde_json::from_str(arguments).unwrap_or_default();
match tool_name {
"brave_search" => args["query"].as_str().unwrap_or("").to_string(),
"web_search" | "brave_search" => args["query"].as_str().unwrap_or("").to_string(),
"web_fetch" => args["url"].as_str().unwrap_or("").to_string(),
_ => arguments.to_string(),
}
}
/// Execute a tool call by name, returning the result as a string.
async fn execute_tool(name: &str, arguments: &str, brave_api_key: &str) -> String {
async fn execute_tool(
name: &str,
arguments: &str,
search_provider: search::SearchProvider,
tavily_api_key: Option<&str>,
brave_api_key: Option<&str>,
) -> String {
match name {
"brave_search" => {
"web_search" | "brave_search" => {
let args: serde_json::Value = serde_json::from_str(arguments).unwrap_or_default();
let query = args["query"].as_str().unwrap_or("").to_string();
let count = args["count"].as_u64().unwrap_or(5) as u8;
@ -440,8 +544,16 @@ async fn execute_tool(name: &str, arguments: &str, brave_api_key: &str) -> Strin
return "Error: search query is required".into();
}
match brave::search(&query, brave_api_key, count).await {
Ok(results) => brave::format_results(&results),
match search::search(
search_provider,
&query,
tavily_api_key,
brave_api_key,
count,
)
.await
{
Ok(results) => search::format_results(&results),
Err(e) => format!("Search error: {}", e),
}
}

View File

@ -1,23 +1,173 @@
//! Application bootstrap for the GroupChat server.
//!
//! This file is responsible for:
//! - loading environment configuration
//! - opening and migrating the SQLite database
//! - constructing shared application state
//! - registering HTTP/WebSocket routes
//! - serving the SPA frontend in production
mod handlers;
mod middleware;
mod models;
mod services;
use axum::{
routing::{get, post},
routing::{get, post, put},
Router,
};
use sqlx::sqlite::SqlitePoolOptions;
use std::sync::Arc;
use tokio::sync::broadcast;
use tower_http::cors::{Any, CorsLayer};
use tower_http::services::{ServeDir, ServeFile};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use crate::services::search::SearchProvider;
/// Extract the file path from a SQLite DATABASE_URL like "sqlite:chat.db?mode=rwc"
fn db_file_path(database_url: &str) -> Option<String> {
let path = database_url.strip_prefix("sqlite:")?;
// Strip query params like ?mode=rwc
let path = path.split('?').next().unwrap_or(path);
Some(path.to_string())
}
/// Create a timestamped backup of the SQLite database file.
/// Backups are stored in a `backups/` directory next to the db file.
/// Only keeps the 10 most recent backups to avoid unbounded disk usage.
fn backup_database(database_url: &str) {
let Some(db_path) = db_file_path(database_url) else {
tracing::warn!("Could not parse database path from URL, skipping backup");
return;
};
let db_file = std::path::Path::new(&db_path);
if !db_file.exists() {
tracing::info!("Database file does not exist yet, skipping backup");
return;
}
// Create backups directory next to the database
let backup_dir = db_file
.parent()
.unwrap_or(std::path::Path::new("."))
.join("backups");
if let Err(e) = std::fs::create_dir_all(&backup_dir) {
tracing::error!("Failed to create backup directory: {}", e);
return;
}
// Build timestamped backup filename: chat.db -> chat_2026-03-09_143022.db
let stem = db_file.file_stem().and_then(|s| s.to_str()).unwrap_or("db");
let ext = db_file.extension().and_then(|s| s.to_str()).unwrap_or("db");
let now = chrono::Local::now();
let backup_name = format!("{}_{}.{}", stem, now.format("%Y-%m-%d_%H%M%S"), ext);
let backup_path = backup_dir.join(&backup_name);
match std::fs::copy(db_file, &backup_path) {
Ok(bytes) => {
tracing::info!(
"Database backup created: {} ({:.1} KB)",
backup_path.display(),
bytes as f64 / 1024.0
);
}
Err(e) => {
tracing::error!("Failed to backup database: {}", e);
return;
}
}
// Also copy WAL and SHM files if they exist (for consistency)
let wal_path = format!("{}-wal", db_path);
let shm_path = format!("{}-shm", db_path);
if std::path::Path::new(&wal_path).exists() {
let wal_backup = backup_dir.join(format!(
"{}_{}.{}-wal",
stem,
now.format("%Y-%m-%d_%H%M%S"),
ext
));
let _ = std::fs::copy(&wal_path, &wal_backup);
}
if std::path::Path::new(&shm_path).exists() {
let shm_backup = backup_dir.join(format!(
"{}_{}.{}-shm",
stem,
now.format("%Y-%m-%d_%H%M%S"),
ext
));
let _ = std::fs::copy(&shm_path, &shm_backup);
}
// Prune old backups: keep only the 10 most recent
prune_old_backups(&backup_dir, stem, 10);
}
/// Remove old backups, keeping only the `keep` most recent ones.
fn prune_old_backups(backup_dir: &std::path::Path, stem: &str, keep: usize) {
let prefix = format!("{}_", stem);
let mut backups: Vec<_> = std::fs::read_dir(backup_dir)
.into_iter()
.flatten()
.filter_map(|e| e.ok())
.filter(|e| {
let name = e.file_name();
let name = name.to_string_lossy();
// Match main db backups (not -wal/-shm)
name.starts_with(&prefix) && !name.ends_with("-wal") && !name.ends_with("-shm")
})
.collect();
if backups.len() <= keep {
return;
}
// Sort by filename (timestamps sort lexicographically)
backups.sort_by_key(|e| e.file_name());
let to_remove = backups.len() - keep;
for entry in backups.into_iter().take(to_remove) {
let path = entry.path();
let name = path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
if let Err(e) = std::fs::remove_file(&path) {
tracing::warn!("Failed to remove old backup {}: {}", name, e);
} else {
tracing::debug!("Pruned old backup: {}", name);
// Also remove associated WAL/SHM backups
let wal = path.with_extension(format!(
"{}-wal",
path.extension().unwrap_or_default().to_string_lossy()
));
let shm = path.with_extension(format!(
"{}-shm",
path.extension().unwrap_or_default().to_string_lossy()
));
let _ = std::fs::remove_file(&wal);
let _ = std::fs::remove_file(&shm);
}
}
}
/// Shared state injected into every handler.
///
/// Axum stores this behind an `Arc`, so handlers can cheaply clone the pointer
/// while all requests still talk to the same database pool, API keys, and
/// broadcast channel.
pub struct AppState {
pub db: sqlx::SqlitePool,
pub jwt_secret: String,
pub openrouter_key: String,
pub brave_api_key: String,
pub search_provider: SearchProvider,
pub tavily_api_key: Option<String>,
pub brave_api_key: Option<String>,
pub tx: broadcast::Sender<models::BroadcastEvent>,
}
@ -32,10 +182,30 @@ async fn main() {
.with(tracing_subscriber::fmt::layer())
.init();
let database_url = std::env::var("DATABASE_URL").unwrap_or_else(|_| "sqlite:chat.db?mode=rwc".into());
// Load the runtime configuration needed to start the server.
let database_url =
std::env::var("DATABASE_URL").unwrap_or_else(|_| "sqlite:chat.db?mode=rwc".into());
let jwt_secret = std::env::var("JWT_SECRET").unwrap_or_else(|_| "dev-secret-change-me".into());
let openrouter_key = std::env::var("OPENROUTER_API_KEY").expect("OPENROUTER_API_KEY must be set");
let brave_api_key = std::env::var("BRAVE_API_KEY").expect("BRAVE_API_KEY must be set");
let openrouter_key =
std::env::var("OPENROUTER_API_KEY").expect("OPENROUTER_API_KEY must be set");
let search_provider =
SearchProvider::from_env(std::env::var("SEARCH_PROVIDER").ok().as_deref())
.unwrap_or_else(|e| panic!("{}", e));
let tavily_api_key = std::env::var("TAVILY_API_KEY").ok();
let brave_api_key = std::env::var("BRAVE_API_KEY").ok();
match search_provider {
SearchProvider::Tavily if tavily_api_key.as_deref().unwrap_or("").is_empty() => {
panic!("TAVILY_API_KEY must be set when SEARCH_PROVIDER=tavily");
}
SearchProvider::Brave if brave_api_key.as_deref().unwrap_or("").is_empty() => {
panic!("BRAVE_API_KEY must be set when SEARCH_PROVIDER=brave");
}
_ => {}
}
// Backup the database before connecting and running migrations
backup_database(&database_url);
let db = SqlitePoolOptions::new()
.max_connections(5)
@ -43,7 +213,8 @@ async fn main() {
.await
.expect("Failed to connect to database");
// Run migrations
// Run migrations in order. Each one is written so startup can safely try it
// again and skip work that already happened in an earlier run.
let migration_sql = include_str!("../migrations/001_init.sql");
sqlx::raw_sql(migration_sql)
.execute(&db)
@ -80,14 +251,80 @@ async fn main() {
Err(e) => panic!("Failed to run migration 004: {}", e),
}
// Run migration 005 - avatar_url on users
let migration_005 = include_str!("../migrations/005_avatar.sql");
match sqlx::raw_sql(migration_005).execute(&db).await {
Ok(_) => tracing::info!("Migration 005 applied"),
Err(e) if e.to_string().contains("duplicate column") => {
tracing::debug!("Migration 005 already applied, skipping");
}
Err(e) => panic!("Failed to run migration 005: {}", e),
}
// Run migration 006 - image_url on messages
let migration_006 = include_str!("../migrations/006_image_url.sql");
match sqlx::raw_sql(migration_006).execute(&db).await {
Ok(_) => tracing::info!("Migration 006 applied"),
Err(e) if e.to_string().contains("duplicate column") => {
tracing::debug!("Migration 006 already applied, skipping");
}
Err(e) => panic!("Failed to run migration 006: {}", e),
}
// Run migration 007 - SHA-256 integrity hash on messages
let migration_007 = include_str!("../migrations/007_message_hash.sql");
match sqlx::raw_sql(migration_007).execute(&db).await {
Ok(_) => {
tracing::info!("Migration 007 applied, backfilling hashes for existing messages...");
// Backfill hashes for all existing messages that don't have one
let rows = sqlx::query_as::<_, (String, String, String)>(
"SELECT id, created_at, content FROM messages WHERE hash IS NULL",
)
.fetch_all(&db)
.await
.unwrap_or_default();
let count = rows.len();
for (id, created_at, content) in rows {
let hash = models::message_hash(&created_at, &content);
let _ = sqlx::query("UPDATE messages SET hash = ? WHERE id = ?")
.bind(&hash)
.bind(&id)
.execute(&db)
.await;
}
if count > 0 {
tracing::info!("Backfilled hashes for {} existing messages", count);
}
}
Err(e) if e.to_string().contains("duplicate column") => {
tracing::debug!("Migration 007 already applied, skipping");
}
Err(e) => panic!("Failed to run migration 007: {}", e),
}
// Run migration 008 - nostr pubkey on users
let migration_008 = include_str!("../migrations/008_nostr.sql");
match sqlx::raw_sql(migration_008).execute(&db).await {
Ok(_) => tracing::info!("Migration 008 applied"),
Err(e) if e.to_string().contains("duplicate column") => {
tracing::debug!("Migration 008 already applied, skipping");
}
Err(e) => panic!("Failed to run migration 008: {}", e),
}
tracing::info!("Database initialized");
// WebSocket tasks subscribe to this channel to receive room events without
// polling the database.
let (tx, _rx) = broadcast::channel::<models::BroadcastEvent>(4096);
let state = Arc::new(AppState {
db,
jwt_secret,
openrouter_key,
search_provider,
tavily_api_key,
brave_api_key,
tx,
});
@ -97,27 +334,77 @@ async fn main() {
.allow_methods(Any)
.allow_headers(Any);
let app = Router::new()
// Serve static files from client dist in production
let static_dir = std::env::var("STATIC_DIR").unwrap_or_else(|_| "../client/dist".into());
// Keep API routes separate from the static-file fallback so `/api/*` and
// `/ws` requests never get mistaken for SPA routes.
let api_routes = Router::new()
// Auth routes
.route("/api/auth/register", post(handlers::auth::register))
.route("/api/auth/login", post(handlers::auth::login))
.route("/api/auth/me", get(handlers::auth::me))
// Nostr auth routes
.route(
"/api/auth/nostr/challenge",
get(handlers::nostr_auth::challenge),
)
.route("/api/auth/nostr/verify", post(handlers::nostr_auth::verify))
// Profile routes
.route("/api/auth/profile", put(handlers::profile::update_profile))
.route(
"/api/auth/avatar",
post(handlers::profile::upload_avatar).delete(handlers::profile::delete_avatar),
)
// Room routes
.route("/api/rooms", get(handlers::rooms::list_rooms).post(handlers::rooms::create_room))
.route("/api/rooms/:room_id", get(handlers::rooms::get_room).delete(handlers::rooms::delete_room))
.route("/api/rooms/:room_id/messages", get(handlers::rooms::get_messages))
.route(
"/api/rooms",
get(handlers::rooms::list_rooms).post(handlers::rooms::create_room),
)
.route(
"/api/rooms/:room_id",
get(handlers::rooms::get_room).delete(handlers::rooms::delete_room),
)
.route(
"/api/rooms/:room_id/messages",
get(handlers::rooms::get_messages),
)
.route("/api/rooms/:room_id/join", post(handlers::rooms::join_room))
.route("/api/rooms/:room_id/clear", post(handlers::rooms::clear_room))
.route(
"/api/rooms/:room_id/clear",
post(handlers::rooms::clear_room),
)
.route(
"/api/messages/hash/:hash",
get(handlers::rooms::resolve_message_hash),
)
// Upload (chat images)
.route("/api/upload", post(handlers::upload::upload_chat_image))
// Models
.route("/api/models", get(handlers::models::list_models))
// Invite routes
.route("/api/invites", post(handlers::invites::create_invite))
.route("/api/invites/:token/accept", post(handlers::invites::accept_invite))
.route(
"/api/invites/:token/accept",
post(handlers::invites::accept_invite),
)
.route(
"/api/invites/nostr",
post(handlers::invites::invite_by_nostr),
)
// Uploaded files (avatars)
.nest_service("/uploads", ServeDir::new("uploads"))
// WebSocket
.route("/ws", get(handlers::ws::ws_handler))
.layer(cors)
.with_state(state);
// SPA fallback: serve static assets, fall back to index.html for client-side routing
let spa = ServeDir::new(&static_dir)
.not_found_service(ServeFile::new(format!("{}/index.html", static_dir)));
let app = api_routes.fallback_service(spa);
let addr = std::env::var("BIND_ADDR").unwrap_or_else(|_| "0.0.0.0:3001".into());
tracing::info!("Server starting on {}", addr);
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();

View File

@ -1,14 +1,11 @@
use async_trait::async_trait;
use axum::{
extract::FromRequestParts,
http::request::Parts,
};
use axum::{extract::FromRequestParts, http::request::Parts};
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
use std::sync::Arc;
use crate::{models::Claims, AppState};
/// Extract authenticated user from JWT in Authorization header
/// Authenticated user information extracted from the bearer token.
pub struct AuthUser {
pub user_id: String,
pub email: String,
@ -19,7 +16,15 @@ pub struct AuthUser {
impl FromRequestParts<Arc<AppState>> for AuthUser {
type Rejection = axum::http::StatusCode;
async fn from_request_parts(parts: &mut Parts, state: &Arc<AppState>) -> Result<Self, Self::Rejection> {
/// Read the `Authorization: Bearer <token>` header and decode the JWT.
///
/// Axum runs this automatically for any handler parameter of type
/// `AuthUser`, which keeps individual handlers free from repeated token
/// parsing logic.
async fn from_request_parts(
parts: &mut Parts,
state: &Arc<AppState>,
) -> Result<Self, Self::Rejection> {
let auth_header = parts
.headers
.get("Authorization")
@ -41,7 +46,16 @@ impl FromRequestParts<Arc<AppState>> for AuthUser {
}
}
pub fn create_token(user_id: &str, email: &str, display_name: &str, secret: &str) -> Result<String, jsonwebtoken::errors::Error> {
/// Create a signed JWT for a logged-in user.
///
/// The token expires after seven days and carries the small amount of identity
/// data the server wants available on every request.
pub fn create_token(
user_id: &str,
email: &str,
display_name: &str,
secret: &str,
) -> Result<String, jsonwebtoken::errors::Error> {
let expiration = chrono::Utc::now()
.checked_add_signed(chrono::Duration::days(7))
.unwrap()
@ -61,6 +75,7 @@ pub fn create_token(user_id: &str, email: &str, display_name: &str, secret: &str
)
}
/// Decode and validate a previously issued JWT.
pub fn decode_token(token: &str, secret: &str) -> Result<Claims, jsonwebtoken::errors::Error> {
let token_data = decode::<Claims>(
token,

View File

@ -1 +1,3 @@
//! Reusable request-processing layers shared across handlers.
pub mod auth;

View File

@ -1,7 +1,14 @@
//! Core data structures shared across the server.
//!
//! This file intentionally mixes database row types, HTTP payloads, WebSocket
//! payloads, and a few helper functions so the rest of the codebase can import
//! common shapes from one place.
use serde::{Deserialize, Serialize};
// ── Database models ──
/// Row from the `users` table.
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct User {
pub id: String,
@ -11,6 +18,7 @@ pub struct User {
pub created_at: String,
}
/// Row from the `rooms` table.
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct Room {
pub id: String,
@ -24,6 +32,7 @@ pub struct Room {
pub deleted_at: Option<String>,
}
/// Row from the `messages` table.
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct Message {
pub id: String,
@ -35,8 +44,10 @@ pub struct Message {
pub is_ai: bool,
pub created_at: String,
pub ai_meta: Option<String>,
pub hash: Option<String>,
}
/// Row from the `invites` table.
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct Invite {
pub id: String,
@ -50,6 +61,7 @@ pub struct Invite {
// ── API request/response types ──
/// JSON body expected by the registration endpoint.
#[derive(Debug, Deserialize)]
pub struct RegisterRequest {
pub email: String,
@ -57,25 +69,33 @@ pub struct RegisterRequest {
pub display_name: String,
}
/// JSON body expected by the login endpoint.
#[derive(Debug, Deserialize)]
pub struct LoginRequest {
pub email: String,
pub password: String,
}
/// Standard auth response returned after login, registration, or profile update.
#[derive(Debug, Serialize)]
pub struct AuthResponse {
pub token: String,
pub user: UserPublic,
}
/// Public user data safe to return to any authenticated client.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UserPublic {
pub id: String,
pub email: String,
pub display_name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub avatar_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub nostr_pubkey: Option<String>,
}
/// JSON body used when a user creates a new chat room.
#[derive(Debug, Deserialize)]
pub struct CreateRoomRequest {
pub name: String,
@ -88,10 +108,10 @@ pub struct CreateRoomRequest {
pub ai_name: String,
}
/// Pick a friendly default AI display name when the creator does not specify one.
fn default_ai_name() -> String {
let names = [
"Nova", "Atlas", "Sage", "Echo", "Pixel",
"Cosmo", "Ember", "Flux", "Lyra", "Onyx",
"Nova", "Atlas", "Sage", "Echo", "Pixel", "Cosmo", "Ember", "Flux", "Lyra", "Onyx",
];
let idx = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
@ -100,10 +120,12 @@ fn default_ai_name() -> String {
names[idx].to_string()
}
/// Default prompt that defines the AI assistant's behavior inside a room.
fn default_system_prompt() -> String {
"You are a helpful AI assistant participating in a group chat. Be conversational, helpful, and concise. You can see messages from all participants. When mentioned with @ai, respond helpfully.\n\nYou have access to tools:\n- **brave_search**: Search the web for current information. Use this when asked about recent events, news, facts you're unsure about, or anything that needs up-to-date information.\n- **web_fetch**: Fetch and read the content of a web page. Use this when a user shares a URL and wants you to read/summarize it, or when you need more details from a search result.\n\nUse tools proactively when they would help answer the question better. You don't need to ask permission to use them.".to_string()
"You are a helpful AI assistant participating in a group chat. Be conversational, helpful, and concise. You can see messages from all participants. When mentioned with @ai, respond helpfully.\n\nYou have access to tools:\n- **web_search**: Search the web for current information. Use this when asked about recent events, news, facts you're unsure about, or anything that needs up-to-date information.\n- **web_fetch**: Fetch and read the content of a web page. Use this when a user shares a URL and wants you to read/summarize it, or when you need more details from a search result.\n\nUse tools proactively when they would help answer the question better. You don't need to ask permission to use them.".to_string()
}
/// Full room payload returned to the client, including current members.
#[derive(Debug, Serialize)]
pub struct RoomResponse {
pub id: String,
@ -117,6 +139,7 @@ pub struct RoomResponse {
pub members: Vec<UserPublic>,
}
/// JSON body for an email-based room invite.
#[derive(Debug, Deserialize)]
pub struct CreateInviteRequest {
pub room_id: String,
@ -125,6 +148,7 @@ pub struct CreateInviteRequest {
// ── WebSocket event types ──
/// Messages the browser can send over the WebSocket connection.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum WsClientMessage {
@ -134,6 +158,8 @@ pub enum WsClientMessage {
content: String,
#[serde(default)]
mentions: Vec<String>,
#[serde(default)]
image_url: Option<String>,
},
#[serde(rename = "join_room")]
JoinRoom { room_id: String },
@ -141,17 +167,14 @@ pub enum WsClientMessage {
Typing { room_id: String },
}
/// Messages the server can push to browsers over the WebSocket connection.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum WsServerMessage {
#[serde(rename = "new_message")]
NewMessage {
message: MessagePayload,
},
NewMessage { message: MessagePayload },
#[serde(rename = "ai_typing")]
AiTyping {
room_id: String,
},
AiTyping { room_id: String },
#[serde(rename = "user_typing")]
UserTyping {
room_id: String,
@ -159,21 +182,13 @@ pub enum WsServerMessage {
display_name: String,
},
#[serde(rename = "error")]
Error {
message: String,
},
Error { message: String },
#[serde(rename = "joined")]
Joined {
room_id: String,
},
Joined { room_id: String },
#[serde(rename = "room_deleted")]
RoomDeleted {
room_id: String,
},
RoomDeleted { room_id: String },
#[serde(rename = "room_cleared")]
RoomCleared {
room_id: String,
},
RoomCleared { room_id: String },
#[serde(rename = "ai_tool_usage")]
AiToolUsage {
room_id: String,
@ -187,12 +202,10 @@ pub enum WsServerMessage {
delta: String,
},
#[serde(rename = "ai_stream_end")]
AiStreamEnd {
room_id: String,
message_id: String,
},
AiStreamEnd { room_id: String, message_id: String },
}
/// Message shape sent to clients for history loading and live updates.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MessagePayload {
pub id: String,
@ -205,8 +218,34 @@ pub struct MessagePayload {
pub created_at: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub ai_meta: Option<AiMeta>,
#[serde(default)]
pub avatar_hash: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub avatar_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub image_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub hash: Option<String>,
}
/// Compute Gravatar-compatible MD5 hash from an email address.
pub fn gravatar_hash(email: &str) -> String {
use md5::{Digest, Md5};
let normalized = email.trim().to_lowercase();
let result = Md5::digest(normalized.as_bytes());
format!("{:x}", result)
}
/// Compute SHA-256 integrity hash from created_at timestamp + message content.
pub fn message_hash(created_at: &str, content: &str) -> String {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(created_at.as_bytes());
hasher.update(content.as_bytes());
format!("{:x}", hasher.finalize())
}
/// Usage and tool metadata captured for AI-generated messages.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AiMeta {
pub model: String,
@ -218,6 +257,7 @@ pub struct AiMeta {
pub tool_results: Option<Vec<ToolResult>>,
}
/// One tool invocation performed while generating an AI answer.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolResult {
pub tool: String,
@ -227,6 +267,7 @@ pub struct ToolResult {
// ── Broadcast event (internal channel) ──
/// Internal event sent through a Tokio broadcast channel to WebSocket tasks.
#[derive(Debug, Clone)]
pub struct BroadcastEvent {
pub room_id: String,
@ -235,6 +276,7 @@ pub struct BroadcastEvent {
// ── JWT Claims ──
/// Claims stored inside the server-issued JWT.
#[derive(Debug, Serialize, Deserialize)]
pub struct Claims {
pub sub: String, // user_id
@ -245,6 +287,7 @@ pub struct Claims {
// ── Pagination ──
/// Common pagination parameters for message history endpoints.
#[derive(Debug, Deserialize)]
pub struct PaginationParams {
#[serde(default = "default_limit")]
@ -255,3 +298,36 @@ pub struct PaginationParams {
fn default_limit() -> i64 {
50
}
/// Hide placeholder `nostr:*` emails from normal client responses.
pub fn public_email(email: &str) -> String {
if email.starts_with("nostr:") {
String::new()
} else {
email.to_string()
}
}
// ── Nostr auth types ──
/// Response returned by the Nostr challenge endpoint.
#[derive(Debug, Serialize)]
pub struct NostrChallengeResponse {
pub challenge: String,
}
/// JSON body sent by the client when proving Nostr ownership.
#[derive(Debug, Deserialize)]
pub struct NostrVerifyRequest {
pub signed_event: String,
pub challenge: String,
pub profile_name: Option<String>,
pub profile_picture: Option<String>,
}
/// JSON body for inviting an already-known Nostr user into a room.
#[derive(Debug, Deserialize)]
pub struct NostrInviteRequest {
pub room_id: String,
pub nostr_pubkey: String,
}

View File

@ -1,7 +1,10 @@
use serde::Deserialize;
use crate::services::search::SearchResult;
const BRAVE_SEARCH_URL: &str = "https://api.search.brave.com/res/v1/web/search";
/// Partial Brave API response containing only the fields this app needs.
#[derive(Debug, Deserialize)]
struct BraveResponse {
web: Option<BraveWebResults>,
@ -23,22 +26,9 @@ struct BraveResult {
extra_snippets: Option<Vec<String>>,
}
/// A simplified search result for consumption by the AI.
#[derive(Debug)]
pub struct SearchResult {
pub title: String,
pub url: String,
pub description: String,
pub age: Option<String>,
}
/// Search the web using the Brave Search API.
/// Returns a list of simplified search results.
pub async fn search(
query: &str,
api_key: &str,
count: u8,
) -> Result<Vec<SearchResult>, String> {
pub async fn search(query: &str, api_key: &str, count: u8) -> Result<Vec<SearchResult>, String> {
let count = count.clamp(1, 10);
let client = reqwest::Client::new();
@ -91,21 +81,3 @@ pub async fn search(
Ok(results)
}
/// Format search results into a readable string for the AI to consume.
pub fn format_results(results: &[SearchResult]) -> String {
if results.is_empty() {
return "No search results found.".to_string();
}
let mut output = String::new();
for (i, r) in results.iter().enumerate() {
output.push_str(&format!("{}. {}\n", i + 1, r.title));
output.push_str(&format!(" URL: {}\n", r.url));
if let Some(age) = &r.age {
output.push_str(&format!(" Age: {}\n", age));
}
output.push_str(&format!(" {}\n\n", r.description));
}
output
}

View File

@ -19,9 +19,29 @@ const STRIP_TAGS: &[&str] = &[
/// Block-level tags that should produce newlines in text output.
const BLOCK_TAGS: &[&str] = &[
"p", "div", "h1", "h2", "h3", "h4", "h5", "h6", "li", "br", "tr",
"blockquote", "pre", "section", "article", "main", "header",
"dt", "dd", "figcaption", "table", "thead", "tbody",
"p",
"div",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"li",
"br",
"tr",
"blockquote",
"pre",
"section",
"article",
"main",
"header",
"dt",
"dd",
"figcaption",
"table",
"thead",
"tbody",
];
/// Fetch a URL and extract its text content.

View File

@ -1,3 +1,11 @@
//! Integrations with external systems used by the chat server.
//!
//! These modules wrap search providers, web page fetching, and the OpenRouter
//! chat completion API so the rest of the application can call them with simple
//! Rust types.
pub mod brave;
pub mod fetch;
pub mod openrouter;
pub mod search;
pub mod tavily;

View File

@ -16,11 +16,34 @@ struct ChatRequest {
stream: Option<bool>,
}
/// Content can be a plain text string or multimodal (text + image) parts.
/// Serializes to a JSON string or a JSON array, matching the OpenAI/OpenRouter API.
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(untagged)]
pub enum Content {
Text(String),
Parts(Vec<ContentPart>),
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(tag = "type")]
pub enum ContentPart {
#[serde(rename = "text")]
Text { text: String },
#[serde(rename = "image_url")]
ImageUrl { image_url: ImageUrlData },
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ImageUrlData {
pub url: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ChatMessage {
pub role: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub content: Option<String>,
pub content: Option<Content>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_calls: Option<Vec<ToolCall>>,
#[serde(skip_serializing_if = "Option::is_none")]
@ -126,13 +149,13 @@ pub struct CompletionStats {
pub response_ms: u64,
}
/// Build the tool definitions for brave_search and web_fetch.
/// Build the tool definitions for web_search and web_fetch.
pub fn build_tools() -> Vec<Tool> {
vec![
Tool {
r#type: "function".into(),
function: ToolFunction {
name: "brave_search".into(),
name: "web_search".into(),
description: "Search the web for current information. Use this when users ask about recent events, need factual data you're unsure about, or want up-to-date information.".into(),
parameters: serde_json::json!({
"type": "object",
@ -212,7 +235,9 @@ pub async fn chat_completion_stream(
{
Ok(r) => r,
Err(e) => {
let _ = tx.send(StreamEvent::Error(format!("Request failed: {}", e))).await;
let _ = tx
.send(StreamEvent::Error(format!("Request failed: {}", e)))
.await;
return;
}
};
@ -220,7 +245,12 @@ pub async fn chat_completion_stream(
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
let _ = tx.send(StreamEvent::Error(format!("OpenRouter error {}: {}", status, body))).await;
let _ = tx
.send(StreamEvent::Error(format!(
"OpenRouter error {}: {}",
status, body
)))
.await;
return;
}
@ -241,7 +271,9 @@ pub async fn chat_completion_stream(
let bytes = match chunk_result {
Ok(b) => b,
Err(e) => {
let _ = tx.send(StreamEvent::Error(format!("Stream error: {}", e))).await;
let _ = tx
.send(StreamEvent::Error(format!("Stream error: {}", e)))
.await;
return;
}
};
@ -315,7 +347,10 @@ pub async fn chat_completion_stream(
tool_call_accum[idx].function.name.push_str(name);
}
if let Some(args) = &func.arguments {
tool_call_accum[idx].function.arguments.push_str(args);
tool_call_accum[idx]
.function
.arguments
.push_str(args);
}
}
}
@ -350,7 +385,11 @@ pub async fn chat_completion_stream(
// AI requested tool calls
let assistant_msg = ChatMessage {
role: "assistant".into(),
content: if full_content.is_empty() { None } else { Some(full_content) },
content: if full_content.is_empty() {
None
} else {
Some(Content::Text(full_content))
},
tool_calls: Some(tool_call_accum),
tool_call_id: None,
};
@ -366,29 +405,49 @@ pub async fn chat_completion_stream(
/// Build the message history for OpenRouter from stored messages.
/// Includes the system prompt as the first message.
/// Messages with image data URLs will be sent as multimodal content.
pub fn build_chat_history(
system_prompt: &str,
messages: &[(String, String, bool)], // (sender_name, content, is_ai)
messages: &[(String, String, bool, Option<String>)], // (sender_name, content, is_ai, image_data_url)
) -> Vec<ChatMessage> {
let mut history = vec![ChatMessage {
role: "system".to_string(),
content: Some(system_prompt.to_string()),
content: Some(Content::Text(system_prompt.to_string())),
tool_calls: None,
tool_call_id: None,
}];
for (sender_name, content, is_ai) in messages {
for (sender_name, content, is_ai, image_data_url) in messages {
if *is_ai {
history.push(ChatMessage {
role: "assistant".to_string(),
content: Some(content.clone()),
content: Some(Content::Text(content.clone())),
tool_calls: None,
tool_call_id: None,
});
} else {
let text = if content.is_empty() {
format!("[{}] shared an image:", sender_name)
} else {
format!("[{}]: {}", sender_name, content)
};
let msg_content = if let Some(data_url) = image_data_url {
Content::Parts(vec![
ContentPart::Text { text },
ContentPart::ImageUrl {
image_url: ImageUrlData {
url: data_url.clone(),
},
},
])
} else {
Content::Text(text)
};
history.push(ChatMessage {
role: "user".to_string(),
content: Some(format!("[{}]: {}", sender_name, content)),
content: Some(msg_content),
tool_calls: None,
tool_call_id: None,
});

View File

@ -0,0 +1,90 @@
use serde::{Deserialize, Serialize};
use super::{brave, tavily};
/// Which search backend the AI tool layer should call.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SearchProvider {
Tavily,
Brave,
}
impl SearchProvider {
/// Parse the `SEARCH_PROVIDER` environment variable into a supported variant.
pub fn from_env(value: Option<&str>) -> Result<Self, String> {
match value
.unwrap_or("tavily")
.trim()
.to_ascii_lowercase()
.as_str()
{
"tavily" => Ok(Self::Tavily),
"brave" => Ok(Self::Brave),
other => Err(format!(
"Unsupported SEARCH_PROVIDER '{}'. Expected 'tavily' or 'brave'.",
other
)),
}
}
/// Return the environment variable name required by the selected provider.
pub fn required_key_name(self) -> &'static str {
match self {
Self::Tavily => "TAVILY_API_KEY",
Self::Brave => "BRAVE_API_KEY",
}
}
}
/// Normalized search result shape shared across providers.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SearchResult {
pub title: String,
pub url: String,
pub description: String,
pub age: Option<String>,
}
/// Dispatch a search request to whichever provider the server is configured to use.
pub async fn search(
provider: SearchProvider,
query: &str,
tavily_api_key: Option<&str>,
brave_api_key: Option<&str>,
count: u8,
) -> Result<Vec<SearchResult>, String> {
match provider {
SearchProvider::Tavily => {
let api_key = tavily_api_key
.filter(|key| !key.is_empty())
.ok_or_else(|| "TAVILY_API_KEY is not configured".to_string())?;
tavily::search(query, api_key, count).await
}
SearchProvider::Brave => {
let api_key = brave_api_key
.filter(|key| !key.is_empty())
.ok_or_else(|| "BRAVE_API_KEY is not configured".to_string())?;
brave::search(query, api_key, count).await
}
}
}
/// Turn search results into plain text the AI model can read as tool output.
pub fn format_results(results: &[SearchResult]) -> String {
if results.is_empty() {
return "No search results found.".to_string();
}
let mut output = String::new();
for (i, r) in results.iter().enumerate() {
output.push_str(&format!("{}. {}\n", i + 1, r.title));
if !r.url.is_empty() {
output.push_str(&format!(" URL: {}\n", r.url));
}
if let Some(age) = &r.age {
output.push_str(&format!(" Age: {}\n", age));
}
output.push_str(&format!(" {}\n\n", r.description));
}
output
}

View File

@ -0,0 +1,90 @@
use serde::Deserialize;
use crate::services::search::SearchResult;
const TAVILY_SEARCH_URL: &str = "https://api.tavily.com/search";
#[derive(Debug, Deserialize)]
struct TavilyResponse {
#[serde(default)]
answer: Option<String>,
#[serde(default)]
results: Vec<TavilyResult>,
}
#[derive(Debug, Deserialize)]
struct TavilyResult {
title: String,
url: String,
#[serde(default)]
content: String,
#[serde(default)]
#[serde(alias = "publishedDate")]
published_date: Option<String>,
}
pub async fn search(query: &str, api_key: &str, count: u8) -> Result<Vec<SearchResult>, String> {
let max_results = count.clamp(1, 10);
let client = reqwest::Client::new();
let response = client
.post(TAVILY_SEARCH_URL)
.header("Authorization", format!("Bearer {}", api_key))
.header("Content-Type", "application/json")
.json(&serde_json::json!({
"query": query,
"topic": "general",
"search_depth": "advanced",
"include_answer": true,
"include_raw_content": false,
"max_results": max_results,
}))
.send()
.await
.map_err(|e| format!("Tavily search request failed: {}", e))?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(format!("Tavily search error {}: {}", status, body));
}
let tavily_response: TavilyResponse = response
.json()
.await
.map_err(|e| format!("Failed to parse Tavily response: {}", e))?;
let answer = tavily_response.answer.unwrap_or_default();
let mut results: Vec<SearchResult> = tavily_response
.results
.into_iter()
.map(|result| SearchResult {
title: result.title,
url: result.url,
description: result.content,
age: result.published_date,
})
.collect();
if !answer.is_empty() {
if let Some(first) = results.first_mut() {
if first.description.is_empty() {
first.description = format!("AI summary: {}", answer);
} else {
first.description = format!(
"AI summary: {}\nSource excerpt: {}",
answer, first.description
);
}
} else {
results.push(SearchResult {
title: "AI Summary".to_string(),
url: String::new(),
description: answer,
age: None,
});
}
}
Ok(results)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB