Compare commits
No commits in common. "master" and "stream" have entirely different histories.
42
.claude/settings.local.json
Normal file
42
.claude/settings.local.json
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"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
11
.gitignore
vendored
@ -3,14 +3,6 @@ server/target/
|
|||||||
server/chat.db
|
server/chat.db
|
||||||
server/chat.db-journal
|
server/chat.db-journal
|
||||||
server/chat.db-wal
|
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
|
# Node
|
||||||
client/node_modules/
|
client/node_modules/
|
||||||
@ -20,9 +12,6 @@ client/dist/
|
|||||||
.env
|
.env
|
||||||
server/.env
|
server/.env
|
||||||
|
|
||||||
# Claude Code (machine-specific)
|
|
||||||
.claude/settings.local.json
|
|
||||||
|
|
||||||
# IDE
|
# IDE
|
||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
|
|||||||
83
README.md
83
README.md
@ -1,83 +0,0 @@
|
|||||||
# 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
|
|
||||||
@ -1,89 +0,0 @@
|
|||||||
# 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).
|
|
||||||
1
client/package-lock.json
generated
1
client/package-lock.json
generated
@ -901,6 +901,7 @@
|
|||||||
"integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==",
|
"integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~5.26.4"
|
"undici-types": "~5.26.4"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,7 +15,6 @@
|
|||||||
user={state.user}
|
user={state.user}
|
||||||
cb-select-room={selectRoom}
|
cb-select-room={selectRoom}
|
||||||
cb-create-room={() => update({ showCreateModal: true })}
|
cb-create-room={() => update({ showCreateModal: true })}
|
||||||
cb-profile={() => update({ showProfileModal: true })}
|
|
||||||
cb-logout={handleLogout}
|
cb-logout={handleLogout}
|
||||||
/>
|
/>
|
||||||
<main class="chat-main">
|
<main class="chat-main">
|
||||||
@ -33,18 +32,7 @@
|
|||||||
cb-delete-room={() => update({ showDeleteModal: true })}
|
cb-delete-room={() => update({ showDeleteModal: true })}
|
||||||
cb-clear-room={() => update({ showClearModal: true })}
|
cb-clear-room={() => update({ showClearModal: true })}
|
||||||
/>
|
/>
|
||||||
<div if={state.linkError} class="no-room">
|
<div if={!state.activeRoom} 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">
|
<div class="no-room-content">
|
||||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
<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"/>
|
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
||||||
@ -81,13 +69,6 @@
|
|||||||
cb-confirm={confirmClearRoom}
|
cb-confirm={confirmClearRoom}
|
||||||
cb-close={() => update({ showClearModal: false })}
|
cb-close={() => update({ showClearModal: false })}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<profile-page
|
|
||||||
if={state.showProfileModal}
|
|
||||||
user={state.user}
|
|
||||||
cb-profile-update={handleProfileUpdate}
|
|
||||||
cb-close={() => update({ showProfileModal: false })}
|
|
||||||
/>
|
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -157,125 +138,50 @@
|
|||||||
.no-room-content p {
|
.no-room-content p {
|
||||||
font-size: var(--text-sm);
|
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>
|
</style>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { api, saveAuth, getUser, clearAuth, isAuthenticated, setOnUnauthorized } from '../services/api.js'
|
import { api, saveAuth, getUser, clearAuth, isAuthenticated } from '../services/api.js'
|
||||||
import { ws } from '../services/websocket.js'
|
import { ws } from '../services/websocket.js'
|
||||||
import { StreamBuffer } from '../services/stream-buffer.js'
|
import { StreamBuffer } from '../services/stream-buffer.js'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
state: {
|
state: {
|
||||||
// Auth + top-level app view state.
|
|
||||||
user: null,
|
user: null,
|
||||||
authView: 'login',
|
authView: 'login',
|
||||||
|
|
||||||
// Active chat data.
|
|
||||||
rooms: [],
|
rooms: [],
|
||||||
activeRoomId: null,
|
activeRoomId: null,
|
||||||
activeRoom: null,
|
activeRoom: null,
|
||||||
messages: [],
|
messages: [],
|
||||||
|
|
||||||
// Modal visibility.
|
|
||||||
showCreateModal: false,
|
showCreateModal: false,
|
||||||
showInviteModal: false,
|
showInviteModal: false,
|
||||||
showDeleteModal: false,
|
showDeleteModal: false,
|
||||||
showClearModal: false,
|
showClearModal: false,
|
||||||
showProfileModal: false,
|
|
||||||
|
|
||||||
// Live AI / typing state for the currently open room.
|
|
||||||
aiTyping: false,
|
aiTyping: false,
|
||||||
aiToolStatus: null,
|
aiToolStatus: null,
|
||||||
streamingMessage: null,
|
streamingMessage: null,
|
||||||
typingUsers: [],
|
typingUsers: [],
|
||||||
|
|
||||||
// Used when a shared message link cannot be opened.
|
|
||||||
linkError: null,
|
|
||||||
},
|
},
|
||||||
|
|
||||||
onMounted() {
|
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()
|
const user = getUser()
|
||||||
if (user && isAuthenticated()) {
|
if (user && isAuthenticated()) {
|
||||||
// Verify the token is still valid with the server before trusting it
|
this.update({ user })
|
||||||
this.verifyAndInit(user)
|
|
||||||
} else {
|
|
||||||
// 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()
|
this.initChat()
|
||||||
} catch (err) {
|
} else {
|
||||||
// Token is expired or invalid — force back to login screen
|
// Not logged in — store invite token so we can accept after login
|
||||||
console.warn('Stored token is no longer valid, logging out:', err.message)
|
|
||||||
clearAuth()
|
|
||||||
ws.disconnect()
|
|
||||||
this.update({ user: null })
|
|
||||||
this.checkPendingInvite()
|
this.checkPendingInvite()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
onUnmounted() {
|
onUnmounted() {
|
||||||
ws.disconnect()
|
ws.disconnect()
|
||||||
window.removeEventListener('hashchange', this._onHashChange)
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async initChat() {
|
async initChat() {
|
||||||
const token = localStorage.getItem('token')
|
const token = localStorage.getItem('token')
|
||||||
ws.connect(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) => {
|
ws.on('new_message', (msg) => {
|
||||||
if (msg.message.room_id === this.state.activeRoomId) {
|
if (msg.message.room_id === this.state.activeRoomId) {
|
||||||
// If we were streaming this message, cancel the buffer and remove placeholder
|
// If we were streaming this message, cancel the buffer and remove placeholder
|
||||||
@ -318,13 +224,11 @@
|
|||||||
mentions: [],
|
mentions: [],
|
||||||
is_ai: true,
|
is_ai: true,
|
||||||
streaming: true,
|
streaming: true,
|
||||||
avatar_hash: '',
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
this.scrollToBottom()
|
this.scrollToBottom()
|
||||||
},
|
},
|
||||||
// onDone: buffer fully drained after stream ended.
|
// onDone: buffer fully drained after stream ended
|
||||||
// At this point the UI can remove the "still typing" cursor effect.
|
|
||||||
() => {
|
() => {
|
||||||
if (this.state.streamingMessage?.id === this._streamMsgId) {
|
if (this.state.streamingMessage?.id === this._streamMsgId) {
|
||||||
this.update({
|
this.update({
|
||||||
@ -366,8 +270,6 @@
|
|||||||
|
|
||||||
ws.on('user_typing', (msg) => {
|
ws.on('user_typing', (msg) => {
|
||||||
if (msg.room_id === this.state.activeRoomId && msg.user_id !== this.state.user.id) {
|
if (msg.room_id === this.state.activeRoomId && msg.user_id !== this.state.user.id) {
|
||||||
// Replace any existing entry for the same user so repeated typing events
|
|
||||||
// refresh the timeout instead of duplicating the person in the UI.
|
|
||||||
const users = this.state.typingUsers.filter(u => u.user_id !== msg.user_id)
|
const users = this.state.typingUsers.filter(u => u.user_id !== msg.user_id)
|
||||||
users.push({ user_id: msg.user_id, display_name: msg.display_name })
|
users.push({ user_id: msg.user_id, display_name: msg.display_name })
|
||||||
this.update({ typingUsers: users })
|
this.update({ typingUsers: users })
|
||||||
@ -405,9 +307,6 @@
|
|||||||
|
|
||||||
// Process any pending invite token
|
// Process any pending invite token
|
||||||
await this.processInviteToken()
|
await this.processInviteToken()
|
||||||
|
|
||||||
// Check for a message permalink in the URL hash (e.g. #roomId/messageHash)
|
|
||||||
this.navigateToMessageLink()
|
|
||||||
},
|
},
|
||||||
|
|
||||||
handleLogin(data) {
|
handleLogin(data) {
|
||||||
@ -429,32 +328,31 @@
|
|||||||
},
|
},
|
||||||
|
|
||||||
async selectRoom(roomId) {
|
async selectRoom(roomId) {
|
||||||
// Cancel any active stream buffer when switching rooms
|
try {
|
||||||
if (this.streamBuffer) {
|
// Cancel any active stream buffer when switching rooms
|
||||||
this.streamBuffer.cancel()
|
if (this.streamBuffer) {
|
||||||
this.streamBuffer = null
|
this.streamBuffer.cancel()
|
||||||
this._streamMsgId = null
|
this.streamBuffer = null
|
||||||
this._streamContent = ''
|
this._streamMsgId = null
|
||||||
}
|
this._streamContent = ''
|
||||||
const [room, messages] = await Promise.all([
|
}
|
||||||
api.getRoom(roomId),
|
const [room, messages] = await Promise.all([
|
||||||
api.getMessages(roomId),
|
api.getRoom(roomId),
|
||||||
])
|
api.getMessages(roomId),
|
||||||
this.update({
|
])
|
||||||
activeRoomId: roomId,
|
this.update({
|
||||||
activeRoom: room,
|
activeRoomId: roomId,
|
||||||
messages,
|
activeRoom: room,
|
||||||
aiTyping: false,
|
messages,
|
||||||
aiToolStatus: null,
|
aiTyping: false,
|
||||||
streamingMessage: null,
|
aiToolStatus: null,
|
||||||
typingUsers: [],
|
streamingMessage: null,
|
||||||
linkError: null,
|
typingUsers: [],
|
||||||
})
|
})
|
||||||
ws.joinRoom(roomId)
|
ws.joinRoom(roomId)
|
||||||
// Joining after room data loads avoids subscribing the UI to a room that failed to open.
|
|
||||||
// Only scroll to bottom if not navigating to a specific message
|
|
||||||
if (!this._pendingScrollHash) {
|
|
||||||
this.scrollToBottom()
|
this.scrollToBottom()
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load room:', e)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -469,8 +367,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
sendMessage({ content, mentions, imageUrl }) {
|
sendMessage({ content, mentions }) {
|
||||||
ws.sendMessage(this.state.activeRoomId, content, mentions, imageUrl)
|
ws.sendMessage(this.state.activeRoomId, content, mentions)
|
||||||
},
|
},
|
||||||
|
|
||||||
handleDeleteRoom(roomId) {
|
handleDeleteRoom(roomId) {
|
||||||
@ -489,18 +387,6 @@
|
|||||||
this.update({ messages: [], showClearModal: false })
|
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 */
|
/** Check URL for /invite/:token and stash it for after login if needed */
|
||||||
checkPendingInvite() {
|
checkPendingInvite() {
|
||||||
const match = window.location.pathname.match(/^\/invite\/(.+)$/)
|
const match = window.location.pathname.match(/^\/invite\/(.+)$/)
|
||||||
@ -542,90 +428,12 @@
|
|||||||
|
|
||||||
scrollToBottom() {
|
scrollToBottom() {
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
// Query the DOM after the frame so Riot has already rendered the latest message list.
|
|
||||||
const container = document.querySelector('.messages-list')
|
const container = document.querySelector('.messages-list')
|
||||||
if (container) {
|
if (container) {
|
||||||
container.scrollTop = container.scrollHeight
|
container.scrollTop = container.scrollHeight
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
/** 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>
|
</script>
|
||||||
</app>
|
</app>
|
||||||
|
|||||||
@ -54,15 +54,7 @@
|
|||||||
<span class="member-role ai-role">AI</span>
|
<span class="member-role ai-role">AI</span>
|
||||||
</div>
|
</div>
|
||||||
<div each={member in props.room?.members} key={member.id} class="member-item">
|
<div each={member in props.room?.members} key={member.id} class="member-item">
|
||||||
<div class="member-avatar">
|
<div class="member-avatar">{member.display_name?.charAt(0).toUpperCase()}</div>
|
||||||
<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 class="member-name">{member.display_name}</span>
|
||||||
<span if={member.id === props.room?.created_by} class="member-role">Owner</span>
|
<span if={member.id === props.room?.created_by} class="member-role">Owner</span>
|
||||||
</div>
|
</div>
|
||||||
@ -72,7 +64,7 @@
|
|||||||
<!-- Messages -->
|
<!-- Messages -->
|
||||||
<div class="messages-list" ref="messagesList">
|
<div class="messages-list" ref="messagesList">
|
||||||
<div class="messages-spacer"></div>
|
<div class="messages-spacer"></div>
|
||||||
<div each={msg in props.messages} key={msg.id} data-hash={msg.hash || ''} id={msg.hash ? 'msg-' + msg.hash : ''}>
|
<div each={msg in props.messages} key={msg.id}>
|
||||||
<message-bubble
|
<message-bubble
|
||||||
message={msg}
|
message={msg}
|
||||||
is-own={msg.sender_id === props.user?.id}
|
is-own={msg.sender_id === props.user?.id}
|
||||||
@ -101,7 +93,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<template if={props.aiToolStatus}>
|
<template if={props.aiToolStatus}>
|
||||||
<span class="tool-status-text">
|
<span class="tool-status-text">
|
||||||
{isSearchTool(props.aiToolStatus.tool) ? '🔍 Searching...' : props.aiToolStatus.tool === 'web_fetch' ? '🌐 Reading page...' : '⚙️ Using tool...'}
|
{props.aiToolStatus.tool === 'brave_search' ? '🔍 Searching...' : props.aiToolStatus.tool === 'web_fetch' ? '🌐 Reading page...' : '⚙️ Using tool...'}
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
<template if={!props.aiToolStatus}>
|
<template if={!props.aiToolStatus}>
|
||||||
@ -118,25 +110,7 @@
|
|||||||
|
|
||||||
<!-- Input -->
|
<!-- Input -->
|
||||||
<div class="message-input-area">
|
<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">
|
<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
|
<textarea
|
||||||
ref="input"
|
ref="input"
|
||||||
class="message-input"
|
class="message-input"
|
||||||
@ -144,12 +118,11 @@
|
|||||||
rows="1"
|
rows="1"
|
||||||
oninput={handleInput}
|
oninput={handleInput}
|
||||||
onkeydown={handleKeydown}
|
onkeydown={handleKeydown}
|
||||||
onpaste={handlePaste}
|
|
||||||
></textarea>
|
></textarea>
|
||||||
<button
|
<button
|
||||||
class="btn btn-primary send-btn"
|
class="btn btn-primary send-btn"
|
||||||
onclick={handleSend}
|
onclick={handleSend}
|
||||||
disabled={!state.inputValue?.trim() && !state.pendingImage}
|
disabled={!state.inputValue?.trim()}
|
||||||
>
|
>
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<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"/>
|
<line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/>
|
||||||
@ -249,14 +222,6 @@
|
|||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.avatar-img {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
border-radius: var(--radius-full);
|
|
||||||
object-fit: cover;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.member-item .ai-avatar {
|
.member-item .ai-avatar {
|
||||||
@ -397,53 +362,6 @@
|
|||||||
border-top: 1px solid var(--border);
|
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 {
|
.input-wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
@ -459,22 +377,6 @@
|
|||||||
border-color: var(--accent);
|
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 {
|
.message-input {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
border: none;
|
border: none;
|
||||||
@ -511,23 +413,15 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { ws } from '../services/websocket.js'
|
import { ws } from '../services/websocket.js'
|
||||||
import { api } from '../services/api.js'
|
|
||||||
import { avatarFromEmail as _avatarFromEmail } from '../services/avatar.js'
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
avatarFromEmail: _avatarFromEmail,
|
|
||||||
|
|
||||||
state: {
|
state: {
|
||||||
inputValue: '',
|
inputValue: '',
|
||||||
typingDisplay: '',
|
typingDisplay: '',
|
||||||
showMembers: false,
|
showMembers: false,
|
||||||
pendingImage: null,
|
|
||||||
pendingImagePreview: null,
|
|
||||||
uploading: false,
|
|
||||||
},
|
},
|
||||||
|
|
||||||
onMounted() {
|
onMounted() {
|
||||||
// Close the members popover when clicking anywhere outside the header controls.
|
|
||||||
this._closeMembers = (e) => {
|
this._closeMembers = (e) => {
|
||||||
if (this.state.showMembers && !this.$('.members-toggle')?.contains(e.target) && !this.$('.members-dropdown')?.contains(e.target)) {
|
if (this.state.showMembers && !this.$('.members-toggle')?.contains(e.target) && !this.$('.members-dropdown')?.contains(e.target)) {
|
||||||
this.update({ showMembers: false })
|
this.update({ showMembers: false })
|
||||||
@ -538,9 +432,6 @@
|
|||||||
|
|
||||||
onUnmounted() {
|
onUnmounted() {
|
||||||
document.removeEventListener('click', this._closeMembers)
|
document.removeEventListener('click', this._closeMembers)
|
||||||
if (this.state.pendingImagePreview) {
|
|
||||||
URL.revokeObjectURL(this.state.pendingImagePreview)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
toggleMembers(e) {
|
toggleMembers(e) {
|
||||||
@ -556,7 +447,7 @@
|
|||||||
textarea.style.height = Math.min(textarea.scrollHeight, 120) + 'px'
|
textarea.style.height = Math.min(textarea.scrollHeight, 120) + 'px'
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert the raw typing user list into the small sentence shown under messages.
|
// Build typing display text
|
||||||
const users = this.props.typingUsers || []
|
const users = this.props.typingUsers || []
|
||||||
let typingDisplay = ''
|
let typingDisplay = ''
|
||||||
if (users.length === 1) {
|
if (users.length === 1) {
|
||||||
@ -583,61 +474,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
handlePaste(e) {
|
handleSend() {
|
||||||
const items = e.clipboardData?.items
|
const content = this.state.inputValue?.trim()
|
||||||
if (!items) return
|
if (!content) 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
|
// Intercept /clear command — room creator only
|
||||||
if (content === '/clear') {
|
if (content === '/clear') {
|
||||||
@ -654,7 +493,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Extract mentions (@ai or @{aiName} detection)
|
// Extract mentions (@ai or @{aiName} detection)
|
||||||
// The server only needs to know whether the AI was mentioned, not the exact text match.
|
|
||||||
const mentions = []
|
const mentions = []
|
||||||
const lc = content.toLowerCase()
|
const lc = content.toLowerCase()
|
||||||
const aiName = this.props.room?.ai_name?.toLowerCase() || ''
|
const aiName = this.props.room?.ai_name?.toLowerCase() || ''
|
||||||
@ -662,34 +500,8 @@
|
|||||||
mentions.push('ai-assistant')
|
mentions.push('ai-assistant')
|
||||||
}
|
}
|
||||||
|
|
||||||
let imageUrl = null
|
this.props.cbSend({ content, mentions })
|
||||||
|
this.update({ inputValue: '' })
|
||||||
// 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')
|
const textarea = this.$('textarea')
|
||||||
if (textarea) {
|
if (textarea) {
|
||||||
@ -697,10 +509,6 @@
|
|||||||
textarea.style.height = 'auto'
|
textarea.style.height = 'auto'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
isSearchTool(toolName) {
|
|
||||||
return toolName === 'web_search' || toolName === 'brave_search'
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
</chat-room>
|
</chat-room>
|
||||||
|
|||||||
@ -32,15 +32,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sidebar-footer">
|
<div class="sidebar-footer">
|
||||||
<div class="user-info user-info-clickable" onclick={props.cbProfile} title="Edit profile">
|
<div class="user-info">
|
||||||
<div class="user-avatar">
|
<div class="user-avatar">
|
||||||
<img src={getUserAvatar()}
|
{props.user?.display_name?.charAt(0).toUpperCase()}
|
||||||
alt={props.user?.display_name}
|
|
||||||
width="32"
|
|
||||||
height="32"
|
|
||||||
class="avatar-img"
|
|
||||||
loading="lazy"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<span class="user-name">{props.user?.display_name}</span>
|
<span class="user-name">{props.user?.display_name}</span>
|
||||||
</div>
|
</div>
|
||||||
@ -178,36 +172,18 @@
|
|||||||
min-width: 0;
|
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 {
|
.user-avatar {
|
||||||
width: 32px;
|
width: 32px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
border-radius: var(--radius-full);
|
border-radius: var(--radius-full);
|
||||||
background: var(--bg-elevated);
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.avatar-img {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
border-radius: var(--radius-full);
|
|
||||||
object-fit: cover;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-name {
|
.user-name {
|
||||||
@ -219,12 +195,6 @@
|
|||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { getAvatarUrl } from '../services/avatar.js'
|
export default {}
|
||||||
|
|
||||||
export default {
|
|
||||||
getUserAvatar() {
|
|
||||||
return getAvatarUrl(this.props.user, 32)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
</chat-sidebar>
|
</chat-sidebar>
|
||||||
|
|||||||
@ -65,7 +65,6 @@
|
|||||||
<div class="model-option-meta">
|
<div class="model-option-meta">
|
||||||
<span class="model-option-id">{model.id}</span>
|
<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.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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -346,15 +345,6 @@
|
|||||||
padding: 1px 5px;
|
padding: 1px 5px;
|
||||||
border-radius: var(--radius-sm);
|
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>
|
</style>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
|||||||
@ -10,18 +10,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="invite-tabs">
|
<form if={!state.inviteUrl} onsubmit={handleSubmit}>
|
||||||
<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">
|
<div class="form-group">
|
||||||
<label for="invite-email">Email address</label>
|
<label for="invite-email">Email address</label>
|
||||||
<input
|
<input
|
||||||
@ -44,7 +33,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div if={state.mode === 'email' && state.inviteUrl} class="invite-success">
|
<div if={state.inviteUrl} class="invite-success">
|
||||||
<p>Invite link generated!</p>
|
<p>Invite link generated!</p>
|
||||||
<div class="invite-link-box">
|
<div class="invite-link-box">
|
||||||
<code>{state.inviteUrl}</code>
|
<code>{state.inviteUrl}</code>
|
||||||
@ -55,39 +44,6 @@
|
|||||||
<button class="btn btn-primary" onclick={props.cbClose}>Done</button>
|
<button class="btn btn-primary" onclick={props.cbClose}>Done</button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -194,49 +150,6 @@
|
|||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
color: var(--text-muted);
|
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>
|
</style>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@ -244,15 +157,10 @@
|
|||||||
|
|
||||||
export default {
|
export default {
|
||||||
state: {
|
state: {
|
||||||
mode: 'email',
|
|
||||||
email: '',
|
email: '',
|
||||||
nostrPubkey: '',
|
|
||||||
error: null,
|
error: null,
|
||||||
loading: false,
|
loading: false,
|
||||||
nostrLoading: false,
|
|
||||||
inviteUrl: null,
|
inviteUrl: null,
|
||||||
nostrResult: null,
|
|
||||||
nostrDisplayName: '',
|
|
||||||
},
|
},
|
||||||
|
|
||||||
handleOverlayClick() {
|
handleOverlayClick() {
|
||||||
@ -277,25 +185,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
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() {
|
copyLink() {
|
||||||
navigator.clipboard.writeText(this.state.inviteUrl)
|
navigator.clipboard.writeText(this.state.inviteUrl)
|
||||||
},
|
},
|
||||||
|
|||||||
@ -38,17 +38,6 @@
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</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">
|
<p class="auth-footer">
|
||||||
Don't have an account?
|
Don't have an account?
|
||||||
<a href="#" onclick={e => { e.preventDefault(); props.cbSwitch() }}>Register</a>
|
<a href="#" onclick={e => { e.preventDefault(); props.cbSwitch() }}>Register</a>
|
||||||
@ -115,47 +104,6 @@
|
|||||||
margin-bottom: var(--space-sm);
|
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 {
|
.auth-footer {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin-top: var(--space-lg);
|
margin-top: var(--space-lg);
|
||||||
@ -166,7 +114,6 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { api } from '../services/api.js'
|
import { api } from '../services/api.js'
|
||||||
import { hasNostrExtension, getPublicKey, signEvent, fetchNostrProfile } from '../services/nostr.js'
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
state: {
|
state: {
|
||||||
@ -174,15 +121,6 @@
|
|||||||
password: '',
|
password: '',
|
||||||
error: null,
|
error: null,
|
||||||
loading: false,
|
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) {
|
async handleSubmit(e) {
|
||||||
@ -191,7 +129,7 @@
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await api.login({
|
const data = await api.login({
|
||||||
email: this.state.email.trim().toLowerCase(),
|
email: this.state.email,
|
||||||
password: this.state.password,
|
password: this.state.password,
|
||||||
})
|
})
|
||||||
this.props.cbLogin(data)
|
this.props.cbLogin(data)
|
||||||
@ -199,47 +137,6 @@
|
|||||||
this.update({ error: err.message, loading: false })
|
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>
|
</script>
|
||||||
</login-page>
|
</login-page>
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
<message-bubble>
|
<message-bubble>
|
||||||
<div class={'message ' + (props.message?.is_ai ? 'ai-message' : '') + (props.isOwn ? ' own-message' : '')}>
|
<div class={'message ' + (props.message?.is_ai ? 'ai-message' : '') + (props.isOwn ? ' own-message' : '')}>
|
||||||
<div class="message-avatar-col">
|
<div if={!props.isOwn} class="message-avatar-col">
|
||||||
<div class={'message-avatar ' + (props.message?.is_ai ? 'ai-avatar' : '')}>
|
<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">
|
<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"/>
|
<rect x="4" y="8" width="16" height="12" rx="2"/>
|
||||||
@ -9,27 +9,22 @@
|
|||||||
<circle cx="9" cy="14" r="1.5" fill="currentColor"/>
|
<circle cx="9" cy="14" r="1.5" fill="currentColor"/>
|
||||||
<circle cx="15" cy="14" r="1.5" fill="currentColor"/>
|
<circle cx="15" cy="14" r="1.5" fill="currentColor"/>
|
||||||
</svg>
|
</svg>
|
||||||
<img if={!props.message?.is_ai}
|
<template if={!props.message?.is_ai}>{props.message?.sender_name?.charAt(0).toUpperCase()}</template>
|
||||||
src={getMessageAvatar(props.message)}
|
|
||||||
alt={props.message?.sender_name}
|
|
||||||
width="32"
|
|
||||||
height="32"
|
|
||||||
class="avatar-img"
|
|
||||||
loading="lazy"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="message-body">
|
<div class="message-body">
|
||||||
<div class={'message-header ' + (props.isOwn ? 'own' : '')}>
|
<div if={!props.isOwn} class="message-header">
|
||||||
<span class="sender-name">{props.message?.sender_name}</span>
|
<span class="sender-name">{props.message?.sender_name}</span>
|
||||||
<span class="message-time" title={fullTimestamp(props.message?.created_at)}>{formatTime(props.message?.created_at)}</span>
|
<span class="message-time">{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={props.isOwn} class="message-header own">
|
||||||
|
<span class="message-time">{formatTime(props.message?.created_at)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div if={hasToolResults()} class="tool-results-section">
|
<div if={hasToolResults()} class="tool-results-section">
|
||||||
<div each={tr in getToolResults()} class="tool-result-item">
|
<div each={tr in getToolResults()} class="tool-result-item">
|
||||||
<button class="tool-result-toggle" onclick={toggleToolResult}>
|
<button class="tool-result-toggle" onclick={toggleToolResult}>
|
||||||
<span class="tool-result-icon">{isSearchTool(tr.tool) ? '🔍' : tr.tool === 'web_fetch' ? '🌐' : '⚙️'}</span>
|
<span class="tool-result-icon">{tr.tool === 'brave_search' ? '🔍' : 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-label">{tr.tool === 'brave_search' ? 'Search' : tr.tool === 'web_fetch' ? 'Fetched' : tr.tool}: {tr.input}</span>
|
||||||
<span class="tool-result-arrow">▼</span>
|
<span class="tool-result-arrow">▼</span>
|
||||||
</button>
|
</button>
|
||||||
<div class="tool-result-body collapsed">
|
<div class="tool-result-body collapsed">
|
||||||
@ -37,38 +32,27 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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 streaming-content">{props.message?.content}<span class="streaming-cursor">▌</span></div>
|
||||||
<div if={!props.isStreaming} class="message-content markdown-content"></div>
|
<div if={!props.isStreaming} class="message-content markdown-content"></div>
|
||||||
<div if={!props.isStreaming} class="message-actions-bar">
|
<div if={props.message?.is_ai && props.message?.ai_meta} class="ai-stats-bar">
|
||||||
<button class="msg-action-btn" onclick={copyFullMessage} title="Copy message">
|
<button class="ai-stat-btn" onclick={copyFullMessage} title="Copy response">
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<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"/>
|
<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>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button if={props.message?.hash} class="msg-action-btn" onclick={copyMessageLink} title="Copy link to message">
|
<span class="ai-stat-item model">
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<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>
|
||||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
|
{formatModel(props.message.ai_meta.model)}
|
||||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
|
</span>
|
||||||
</svg>
|
<span class="ai-stat-item" title="Generation speed">
|
||||||
</button>
|
⚡ {calcSpeed(props.message.ai_meta)} tok/sec
|
||||||
<template if={props.message?.is_ai && props.message?.ai_meta}>
|
</span>
|
||||||
<span class="ai-stat-item model">
|
<span class="ai-stat-item" title="Completion tokens">
|
||||||
<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>
|
🎯 {props.message.ai_meta.completion_tokens} tokens
|
||||||
{formatModel(props.message.ai_meta.model)}
|
</span>
|
||||||
</span>
|
<span class="ai-stat-item" title="Response time">
|
||||||
<span class="ai-stat-item" title="Generation speed">
|
⏱ {(props.message.ai_meta.response_ms / 1000).toFixed(1)}s
|
||||||
⚡ {calcSpeed(props.message.ai_meta)} tok/sec
|
</span>
|
||||||
</span>
|
|
||||||
<span class="ai-stat-item" title="Completion tokens">
|
|
||||||
🎯 {props.message.ai_meta.completion_tokens} tokens
|
|
||||||
</span>
|
|
||||||
<span class="ai-stat-item" title="Response time">
|
|
||||||
⏱ {(props.message.ai_meta.response_ms / 1000).toFixed(1)}s
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -107,14 +91,6 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.avatar-img {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
border-radius: var(--radius-full);
|
|
||||||
object-fit: cover;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-avatar {
|
.ai-avatar {
|
||||||
@ -135,7 +111,6 @@
|
|||||||
|
|
||||||
.message-header.own {
|
.message-header.own {
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
flex-direction: row-reverse;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.sender-name {
|
.sender-name {
|
||||||
@ -153,20 +128,6 @@
|
|||||||
color: var(--text-muted);
|
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 {
|
.message-content {
|
||||||
padding: var(--space-sm) var(--space-md);
|
padding: var(--space-sm) var(--space-md);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
@ -285,7 +246,7 @@
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-actions-bar {
|
.ai-stats-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-sm);
|
gap: var(--space-sm);
|
||||||
@ -294,21 +255,9 @@
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
opacity: 0;
|
|
||||||
transition: opacity var(--transition-fast);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.message:hover .message-actions-bar,
|
.ai-stat-btn {
|
||||||
.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;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@ -319,12 +268,12 @@
|
|||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.msg-action-btn:hover {
|
.ai-stat-btn:hover {
|
||||||
background: var(--bg-hover);
|
background: var(--bg-hover);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.msg-action-btn.copied {
|
.ai-stat-btn.copied {
|
||||||
color: var(--success);
|
color: var(--success);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -340,27 +289,6 @@
|
|||||||
font-weight: 500;
|
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 {
|
.streaming-content {
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-wrap: break-word;
|
word-wrap: break-word;
|
||||||
@ -380,15 +308,8 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { renderMarkdown } from '../services/markdown.js'
|
import { renderMarkdown } from '../services/markdown.js'
|
||||||
import { avatarFromHash } from '../services/avatar.js'
|
|
||||||
|
|
||||||
export default {
|
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() {
|
onMounted() {
|
||||||
this.renderContent()
|
this.renderContent()
|
||||||
},
|
},
|
||||||
@ -401,7 +322,6 @@
|
|||||||
if (this.props.isStreaming) return // Don't markdown-render while streaming
|
if (this.props.isStreaming) return // Don't markdown-render while streaming
|
||||||
const el = this.$('.message-content.markdown-content')
|
const el = this.$('.message-content.markdown-content')
|
||||||
if (el && this.props.message?.content) {
|
if (el && this.props.message?.content) {
|
||||||
// Riot renders the container, then markdown-it fills in the trusted HTML output.
|
|
||||||
el.innerHTML = renderMarkdown(this.props.message.content)
|
el.innerHTML = renderMarkdown(this.props.message.content)
|
||||||
// Inject copy buttons into code blocks
|
// Inject copy buttons into code blocks
|
||||||
el.querySelectorAll('pre').forEach((pre) => {
|
el.querySelectorAll('pre').forEach((pre) => {
|
||||||
@ -432,14 +352,6 @@
|
|||||||
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
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) {
|
formatModel(model) {
|
||||||
if (!model) return 'unknown'
|
if (!model) return 'unknown'
|
||||||
// "openai/gpt-4o" → "gpt-4o", "anthropic/claude-3.5-sonnet" → "claude-3.5-sonnet"
|
// "openai/gpt-4o" → "gpt-4o", "anthropic/claude-3.5-sonnet" → "claude-3.5-sonnet"
|
||||||
@ -463,15 +375,10 @@
|
|||||||
return this.props.message?.ai_meta?.tool_results || []
|
return this.props.message?.ai_meta?.tool_results || []
|
||||||
},
|
},
|
||||||
|
|
||||||
isSearchTool(toolName) {
|
|
||||||
return toolName === 'web_search' || toolName === 'brave_search'
|
|
||||||
},
|
|
||||||
|
|
||||||
toggleToolResult(e) {
|
toggleToolResult(e) {
|
||||||
const toggle = e.currentTarget
|
const toggle = e.currentTarget
|
||||||
const body = toggle.nextElementSibling
|
const body = toggle.nextElementSibling
|
||||||
const isOpen = toggle.classList.contains('open')
|
const isOpen = toggle.classList.contains('open')
|
||||||
// Keep the collapse logic local to each item so tool results stay independent.
|
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
toggle.classList.remove('open')
|
toggle.classList.remove('open')
|
||||||
body.classList.add('collapsed')
|
body.classList.add('collapsed')
|
||||||
@ -481,11 +388,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
openImageFullscreen(e) {
|
|
||||||
const url = e.target.src
|
|
||||||
window.open(url, '_blank')
|
|
||||||
},
|
|
||||||
|
|
||||||
copyFullMessage(e) {
|
copyFullMessage(e) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
@ -497,27 +399,7 @@
|
|||||||
btn.title = 'Copied!'
|
btn.title = 'Copied!'
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
btn.classList.remove('copied')
|
btn.classList.remove('copied')
|
||||||
btn.title = 'Copy message'
|
btn.title = 'Copy response'
|
||||||
}, 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)
|
}, 2000)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,313 +0,0 @@
|
|||||||
<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>
|
|
||||||
@ -51,17 +51,6 @@
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</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">
|
<p class="auth-footer">
|
||||||
Already have an account?
|
Already have an account?
|
||||||
<a href="#" onclick={e => { e.preventDefault(); props.cbSwitch() }}>Sign in</a>
|
<a href="#" onclick={e => { e.preventDefault(); props.cbSwitch() }}>Sign in</a>
|
||||||
@ -128,47 +117,6 @@
|
|||||||
margin-bottom: var(--space-sm);
|
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 {
|
.auth-footer {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin-top: var(--space-lg);
|
margin-top: var(--space-lg);
|
||||||
@ -179,7 +127,6 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { api } from '../services/api.js'
|
import { api } from '../services/api.js'
|
||||||
import { hasNostrExtension, getPublicKey, signEvent, fetchNostrProfile } from '../services/nostr.js'
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
state: {
|
state: {
|
||||||
@ -188,14 +135,6 @@
|
|||||||
password: '',
|
password: '',
|
||||||
error: null,
|
error: null,
|
||||||
loading: false,
|
loading: false,
|
||||||
hasNostr: false,
|
|
||||||
nostrLoading: false,
|
|
||||||
},
|
|
||||||
|
|
||||||
onMounted() {
|
|
||||||
setTimeout(() => {
|
|
||||||
this.update({ hasNostr: hasNostrExtension() })
|
|
||||||
}, 100)
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async handleSubmit(e) {
|
async handleSubmit(e) {
|
||||||
@ -204,47 +143,15 @@
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await api.register({
|
const data = await api.register({
|
||||||
email: this.state.email.trim().toLowerCase(),
|
email: this.state.email,
|
||||||
password: this.state.password,
|
password: this.state.password,
|
||||||
display_name: this.state.display_name.trim(),
|
display_name: this.state.display_name,
|
||||||
})
|
})
|
||||||
this.props.cbRegister(data)
|
this.props.cbRegister(data)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.update({ error: err.message, loading: false })
|
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>
|
</script>
|
||||||
</register-page>
|
</register-page>
|
||||||
|
|||||||
@ -10,7 +10,6 @@ import InviteModal from './components/invite-modal.riot'
|
|||||||
import DeleteRoomModal from './components/delete-room-modal.riot'
|
import DeleteRoomModal from './components/delete-room-modal.riot'
|
||||||
import ClearConfirmModal from './components/clear-confirm-modal.riot'
|
import ClearConfirmModal from './components/clear-confirm-modal.riot'
|
||||||
import MessageBubble from './components/message-bubble.riot'
|
import MessageBubble from './components/message-bubble.riot'
|
||||||
import ProfilePage from './components/profile-page.riot'
|
|
||||||
|
|
||||||
// Register all components
|
// Register all components
|
||||||
register('login-page', LoginPage)
|
register('login-page', LoginPage)
|
||||||
@ -22,7 +21,6 @@ register('invite-modal', InviteModal)
|
|||||||
register('delete-room-modal', DeleteRoomModal)
|
register('delete-room-modal', DeleteRoomModal)
|
||||||
register('clear-confirm-modal', ClearConfirmModal)
|
register('clear-confirm-modal', ClearConfirmModal)
|
||||||
register('message-bubble', MessageBubble)
|
register('message-bubble', MessageBubble)
|
||||||
register('profile-page', ProfilePage)
|
|
||||||
|
|
||||||
// Mount the app
|
// Mount the app
|
||||||
const mountApp = component(App)
|
const mountApp = component(App)
|
||||||
|
|||||||
@ -1,24 +1,15 @@
|
|||||||
const API_BASE = '/api'
|
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() {
|
function getToken() {
|
||||||
return localStorage.getItem('token')
|
return localStorage.getItem('token')
|
||||||
}
|
}
|
||||||
|
|
||||||
function authHeaders() {
|
function authHeaders() {
|
||||||
// Keep auth header creation in one place so every request follows the same rule.
|
|
||||||
const token = getToken()
|
const token = getToken()
|
||||||
return token ? { Authorization: `Bearer ${token}` } : {}
|
return token ? { Authorization: `Bearer ${token}` } : {}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function request(method, path, body) {
|
async function request(method, path, body) {
|
||||||
// Most client API calls are JSON and share the same error handling path.
|
|
||||||
const opts = {
|
const opts = {
|
||||||
method,
|
method,
|
||||||
headers: {
|
headers: {
|
||||||
@ -34,15 +25,8 @@ async function request(method, path, body) {
|
|||||||
const res = await fetch(`${API_BASE}${path}`, opts)
|
const res = await fetch(`${API_BASE}${path}`, opts)
|
||||||
|
|
||||||
if (!res.ok) {
|
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()
|
const text = await res.text()
|
||||||
const err = new Error(text || `HTTP ${res.status}`)
|
throw new Error(text || `HTTP ${res.status}`)
|
||||||
err.status = res.status
|
|
||||||
throw err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (res.status === 204 || res.headers.get('content-length') === '0') {
|
if (res.status === 204 || res.headers.get('content-length') === '0') {
|
||||||
@ -58,30 +42,11 @@ export const api = {
|
|||||||
login: (data) => request('POST', '/auth/login', data),
|
login: (data) => request('POST', '/auth/login', data),
|
||||||
me: () => request('GET', '/auth/me'),
|
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
|
// Rooms
|
||||||
listRooms: () => request('GET', '/rooms'),
|
listRooms: () => request('GET', '/rooms'),
|
||||||
createRoom: (data) => request('POST', '/rooms', data),
|
createRoom: (data) => request('POST', '/rooms', data),
|
||||||
getRoom: (roomId) => request('GET', `/rooms/${roomId}`),
|
getRoom: (roomId) => request('GET', `/rooms/${roomId}`),
|
||||||
getMessages: (roomId, limit = 50, before) => {
|
getMessages: (roomId, limit = 50, before) => {
|
||||||
// `before` supports paginating older messages without changing the base endpoint.
|
|
||||||
const params = new URLSearchParams({ limit: String(limit) })
|
const params = new URLSearchParams({ limit: String(limit) })
|
||||||
if (before) params.set('before', before)
|
if (before) params.set('before', before)
|
||||||
return request('GET', `/rooms/${roomId}/messages?${params}`)
|
return request('GET', `/rooms/${roomId}/messages?${params}`)
|
||||||
@ -89,39 +54,16 @@ export const api = {
|
|||||||
joinRoom: (roomId) => request('POST', `/rooms/${roomId}/join`),
|
joinRoom: (roomId) => request('POST', `/rooms/${roomId}/join`),
|
||||||
deleteRoom: (roomId) => request('DELETE', `/rooms/${roomId}`),
|
deleteRoom: (roomId) => request('DELETE', `/rooms/${roomId}`),
|
||||||
clearRoom: (roomId) => request('POST', `/rooms/${roomId}/clear`),
|
clearRoom: (roomId) => request('POST', `/rooms/${roomId}/clear`),
|
||||||
resolveMessageHash: (hash) => request('GET', `/messages/hash/${hash}`),
|
|
||||||
|
|
||||||
// Models
|
// Models
|
||||||
listModels: () => request('GET', '/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
|
// Invites
|
||||||
createInvite: (data) => request('POST', '/invites', data),
|
createInvite: (data) => request('POST', '/invites', data),
|
||||||
acceptInvite: (token) => request('POST', `/invites/${token}/accept`),
|
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) {
|
export function saveAuth(token, user) {
|
||||||
// Token + user stay together so the UI can repaint immediately on refresh.
|
|
||||||
localStorage.setItem('token', token)
|
localStorage.setItem('token', token)
|
||||||
localStorage.setItem('user', JSON.stringify(user))
|
localStorage.setItem('user', JSON.stringify(user))
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,133 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,7 +1,6 @@
|
|||||||
import MarkdownIt from 'markdown-it'
|
import MarkdownIt from 'markdown-it'
|
||||||
import hljs from 'highlight.js'
|
import hljs from 'highlight.js'
|
||||||
|
|
||||||
// One shared renderer keeps markdown output consistent everywhere messages appear.
|
|
||||||
const md = new MarkdownIt({
|
const md = new MarkdownIt({
|
||||||
html: false,
|
html: false,
|
||||||
linkify: true,
|
linkify: true,
|
||||||
|
|||||||
@ -1,69 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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 {}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@ -10,7 +10,6 @@ class WebSocketManager {
|
|||||||
this.reconnectDelay = 1000
|
this.reconnectDelay = 1000
|
||||||
this.maxReconnectDelay = 30000
|
this.maxReconnectDelay = 30000
|
||||||
this.token = null
|
this.token = null
|
||||||
// Track joined rooms so reconnect can restore live updates automatically.
|
|
||||||
this.subscribedRooms = new Set()
|
this.subscribedRooms = new Set()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -18,7 +17,6 @@ class WebSocketManager {
|
|||||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) return
|
if (this.ws && this.ws.readyState === WebSocket.OPEN) return
|
||||||
|
|
||||||
this.token = token
|
this.token = token
|
||||||
this._authFailed = false
|
|
||||||
|
|
||||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||||
const host = window.location.host
|
const host = window.location.host
|
||||||
@ -27,7 +25,6 @@ class WebSocketManager {
|
|||||||
this.ws.onopen = () => {
|
this.ws.onopen = () => {
|
||||||
console.log('[WS] Connected')
|
console.log('[WS] Connected')
|
||||||
this.reconnectDelay = 1000
|
this.reconnectDelay = 1000
|
||||||
this._authFailed = false
|
|
||||||
|
|
||||||
// Re-subscribe to all rooms we were watching
|
// Re-subscribe to all rooms we were watching
|
||||||
for (const roomId of this.subscribedRooms) {
|
for (const roomId of this.subscribedRooms) {
|
||||||
@ -49,25 +46,8 @@ class WebSocketManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.ws.onclose = (event) => {
|
this.ws.onclose = (event) => {
|
||||||
console.log('[WS] Disconnected, code:', event.code)
|
console.log('[WS] Disconnected', event.code)
|
||||||
this.emit('disconnected')
|
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) {
|
if (this.token) {
|
||||||
this.scheduleReconnect()
|
this.scheduleReconnect()
|
||||||
}
|
}
|
||||||
@ -81,7 +61,6 @@ class WebSocketManager {
|
|||||||
disconnect() {
|
disconnect() {
|
||||||
this.token = null
|
this.token = null
|
||||||
this.subscribedRooms.clear()
|
this.subscribedRooms.clear()
|
||||||
this.listeners.clear()
|
|
||||||
if (this.reconnectTimer) {
|
if (this.reconnectTimer) {
|
||||||
clearTimeout(this.reconnectTimer)
|
clearTimeout(this.reconnectTimer)
|
||||||
this.reconnectTimer = null
|
this.reconnectTimer = null
|
||||||
@ -119,21 +98,18 @@ class WebSocketManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
joinRoom(roomId) {
|
joinRoom(roomId) {
|
||||||
// Joining is idempotent: we keep the room in the set and let the server ignore duplicates.
|
|
||||||
this.subscribedRooms.add(roomId)
|
this.subscribedRooms.add(roomId)
|
||||||
this.send({ type: 'join_room', room_id: roomId })
|
this.send({ type: 'join_room', room_id: roomId })
|
||||||
}
|
}
|
||||||
|
|
||||||
sendMessage(roomId, content, mentions = [], imageUrl = null) {
|
sendMessage(roomId, content, mentions = []) {
|
||||||
console.log('[WS] Sending message to room:', roomId)
|
console.log('[WS] Sending message to room:', roomId)
|
||||||
const msg = {
|
this.send({
|
||||||
type: 'send_message',
|
type: 'send_message',
|
||||||
room_id: roomId,
|
room_id: roomId,
|
||||||
content,
|
content,
|
||||||
mentions,
|
mentions,
|
||||||
}
|
})
|
||||||
if (imageUrl) msg.image_url = imageUrl
|
|
||||||
this.send(msg)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
sendTyping(roomId) {
|
sendTyping(roomId) {
|
||||||
|
|||||||
@ -18,26 +18,19 @@ function riotPlugin() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const apiPort = process.env.VITE_API_PORT || '3001'
|
|
||||||
const clientPort = parseInt(process.env.VITE_PORT || '3000')
|
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [riotPlugin()],
|
plugins: [riotPlugin()],
|
||||||
server: {
|
server: {
|
||||||
port: clientPort,
|
port: 3000,
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: `http://localhost:${apiPort}`,
|
target: 'http://localhost:3001',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
'/ws': {
|
'/ws': {
|
||||||
target: `ws://localhost:${apiPort}`,
|
target: 'ws://localhost:3001',
|
||||||
ws: true,
|
ws: true,
|
||||||
},
|
},
|
||||||
'/uploads': {
|
|
||||||
target: `http://localhost:${apiPort}`,
|
|
||||||
changeOrigin: true,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
94
dev.sh
94
dev.sh
@ -1,94 +0,0 @@
|
|||||||
#!/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
162
prod.sh
@ -1,162 +0,0 @@
|
|||||||
#!/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
|
|
||||||
@ -10,15 +10,3 @@ JWT_SECRET=change-me-to-a-random-secret
|
|||||||
|
|
||||||
# OpenRouter API
|
# OpenRouter API
|
||||||
OPENROUTER_API_KEY=sk-or-v1-your-key-here
|
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
269
server/Cargo.lock
generated
@ -8,16 +8,6 @@ version = "2.0.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
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]]
|
[[package]]
|
||||||
name = "ahash"
|
name = "ahash"
|
||||||
version = "0.8.12"
|
version = "0.8.12"
|
||||||
@ -88,12 +78,6 @@ dependencies = [
|
|||||||
"password-hash",
|
"password-hash",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "arrayvec"
|
|
||||||
version = "0.7.6"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "async-compression"
|
name = "async-compression"
|
||||||
version = "0.4.41"
|
version = "0.4.41"
|
||||||
@ -159,7 +143,6 @@ dependencies = [
|
|||||||
"matchit",
|
"matchit",
|
||||||
"memchr",
|
"memchr",
|
||||||
"mime",
|
"mime",
|
||||||
"multer",
|
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"rustversion",
|
"rustversion",
|
||||||
@ -251,40 +234,6 @@ version = "1.8.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
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]]
|
[[package]]
|
||||||
name = "bitflags"
|
name = "bitflags"
|
||||||
version = "2.11.0"
|
version = "2.11.0"
|
||||||
@ -312,15 +261,6 @@ dependencies = [
|
|||||||
"generic-array",
|
"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]]
|
[[package]]
|
||||||
name = "brotli"
|
name = "brotli"
|
||||||
version = "8.0.2"
|
version = "8.0.2"
|
||||||
@ -360,15 +300,6 @@ version = "1.11.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "cbc"
|
|
||||||
version = "0.1.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6"
|
|
||||||
dependencies = [
|
|
||||||
"cipher",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cc"
|
name = "cc"
|
||||||
version = "1.2.56"
|
version = "1.2.56"
|
||||||
@ -385,30 +316,6 @@ version = "1.0.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
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]]
|
[[package]]
|
||||||
name = "chrono"
|
name = "chrono"
|
||||||
version = "0.4.44"
|
version = "0.4.44"
|
||||||
@ -423,17 +330,6 @@ dependencies = [
|
|||||||
"windows-link",
|
"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]]
|
[[package]]
|
||||||
name = "compression-codecs"
|
name = "compression-codecs"
|
||||||
version = "0.4.37"
|
version = "0.4.37"
|
||||||
@ -539,7 +435,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"generic-array",
|
"generic-array",
|
||||||
"rand_core",
|
|
||||||
"typenum",
|
"typenum",
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -585,9 +480,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "deranged"
|
name = "deranged"
|
||||||
version = "0.3.11"
|
version = "0.5.8"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4"
|
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"powerfmt",
|
"powerfmt",
|
||||||
]
|
]
|
||||||
@ -956,19 +851,15 @@ dependencies = [
|
|||||||
"async-trait",
|
"async-trait",
|
||||||
"axum",
|
"axum",
|
||||||
"axum-extra",
|
"axum-extra",
|
||||||
"base64 0.22.1",
|
|
||||||
"chrono",
|
"chrono",
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
"futures",
|
"futures",
|
||||||
"jsonwebtoken",
|
"jsonwebtoken",
|
||||||
"md-5",
|
|
||||||
"nostr",
|
|
||||||
"rand",
|
"rand",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"scraper",
|
"scraper",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
|
||||||
"sqlx",
|
"sqlx",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tower 0.4.13",
|
"tower 0.4.13",
|
||||||
@ -1076,15 +967,6 @@ version = "0.4.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
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]]
|
[[package]]
|
||||||
name = "hkdf"
|
name = "hkdf"
|
||||||
version = "0.12.4"
|
version = "0.12.4"
|
||||||
@ -1399,28 +1281,6 @@ dependencies = [
|
|||||||
"serde_core",
|
"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]]
|
[[package]]
|
||||||
name = "ipnet"
|
name = "ipnet"
|
||||||
version = "2.12.0"
|
version = "2.12.0"
|
||||||
@ -1700,30 +1560,6 @@ dependencies = [
|
|||||||
"minimal-lexical",
|
"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]]
|
[[package]]
|
||||||
name = "nu-ansi-term"
|
name = "nu-ansi-term"
|
||||||
version = "0.50.3"
|
version = "0.50.3"
|
||||||
@ -1761,9 +1597,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-conv"
|
name = "num-conv"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
|
checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-integer"
|
name = "num-integer"
|
||||||
@ -1801,12 +1637,6 @@ version = "1.21.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "opaque-debug"
|
|
||||||
version = "0.3.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openssl"
|
name = "openssl"
|
||||||
version = "0.10.75"
|
version = "0.10.75"
|
||||||
@ -1891,16 +1721,6 @@ version = "1.0.15"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
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]]
|
[[package]]
|
||||||
name = "pem"
|
name = "pem"
|
||||||
version = "3.0.6"
|
version = "3.0.6"
|
||||||
@ -2023,17 +1843,6 @@ version = "0.2.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
|
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]]
|
[[package]]
|
||||||
name = "potential_utf"
|
name = "potential_utf"
|
||||||
version = "0.1.4"
|
version = "0.1.4"
|
||||||
@ -2304,15 +2113,6 @@ version = "1.0.23"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "salsa20"
|
|
||||||
version = "0.10.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213"
|
|
||||||
dependencies = [
|
|
||||||
"cipher",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "schannel"
|
name = "schannel"
|
||||||
version = "0.1.28"
|
version = "0.1.28"
|
||||||
@ -2343,38 +2143,6 @@ dependencies = [
|
|||||||
"tendril",
|
"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]]
|
[[package]]
|
||||||
name = "security-framework"
|
name = "security-framework"
|
||||||
version = "3.7.0"
|
version = "3.7.0"
|
||||||
@ -2563,9 +2331,9 @@ checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "simple_asn1"
|
name = "simple_asn1"
|
||||||
version = "0.6.3"
|
version = "0.6.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "297f631f50729c8c99b84667867963997ec0b50f32b2a7dbcab828ef0541e8bb"
|
checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"num-bigint",
|
"num-bigint",
|
||||||
"num-traits",
|
"num-traits",
|
||||||
@ -3018,30 +2786,30 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "time"
|
name = "time"
|
||||||
version = "0.3.37"
|
version = "0.3.47"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "35e7868883861bd0e56d9ac6efcaaca0d6d5d82a2a7ec8209ff492c07cf37b21"
|
checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"deranged",
|
"deranged",
|
||||||
"itoa",
|
"itoa",
|
||||||
"num-conv",
|
"num-conv",
|
||||||
"powerfmt",
|
"powerfmt",
|
||||||
"serde",
|
"serde_core",
|
||||||
"time-core",
|
"time-core",
|
||||||
"time-macros",
|
"time-macros",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "time-core"
|
name = "time-core"
|
||||||
version = "0.1.2"
|
version = "0.1.8"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3"
|
checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "time-macros"
|
name = "time-macros"
|
||||||
version = "0.2.19"
|
version = "0.2.27"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2834e6017e3e5e4b9834939793b282bc03b37a3336245fa820e35e233e2a85de"
|
checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"num-conv",
|
"num-conv",
|
||||||
"time-core",
|
"time-core",
|
||||||
@ -3392,16 +3160,6 @@ version = "0.1.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e"
|
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]]
|
[[package]]
|
||||||
name = "untrusted"
|
name = "untrusted"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
@ -3418,7 +3176,6 @@ dependencies = [
|
|||||||
"idna",
|
"idna",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_derive",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@ -4,7 +4,7 @@ version = "0.1.0"
|
|||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
axum = { version = "0.7", features = ["ws", "macros", "multipart"] }
|
axum = { version = "0.7", features = ["ws", "macros"] }
|
||||||
axum-extra = { version = "0.9", features = ["typed-header"] }
|
axum-extra = { version = "0.9", features = ["typed-header"] }
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
tower = "0.4"
|
tower = "0.4"
|
||||||
@ -24,7 +24,3 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
|||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
scraper = "0.22"
|
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
155
server/README.md
@ -1,155 +0,0 @@
|
|||||||
# 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).
|
|
||||||
@ -1,2 +0,0 @@
|
|||||||
-- Add avatar_url column to users table for custom avatar uploads
|
|
||||||
ALTER TABLE users ADD COLUMN avatar_url TEXT;
|
|
||||||
@ -1 +0,0 @@
|
|||||||
ALTER TABLE messages ADD COLUMN image_url TEXT;
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
-- 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.
|
|
||||||
@ -1,2 +0,0 @@
|
|||||||
ALTER TABLE users ADD COLUMN nostr_pubkey TEXT;
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_nostr_pubkey ON users(nostr_pubkey);
|
|
||||||
@ -1,36 +1,24 @@
|
|||||||
|
use axum::{extract::State, http::StatusCode, Json};
|
||||||
use argon2::{
|
use argon2::{
|
||||||
password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
|
password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
|
||||||
Argon2,
|
Argon2,
|
||||||
};
|
};
|
||||||
use axum::{extract::State, http::StatusCode, Json};
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
middleware::auth::{create_token, AuthUser},
|
middleware::auth::{create_token, AuthUser},
|
||||||
models::{self, AuthResponse, LoginRequest, RegisterRequest, UserPublic},
|
models::{AuthResponse, LoginRequest, RegisterRequest, UserPublic},
|
||||||
AppState,
|
AppState,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Create a new password-based account and immediately return a JWT.
|
|
||||||
pub async fn register(
|
pub async fn register(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
Json(body): Json<RegisterRequest>,
|
Json(body): Json<RegisterRequest>,
|
||||||
) -> Result<Json<AuthResponse>, (StatusCode, String)> {
|
) -> Result<Json<AuthResponse>, (StatusCode, String)> {
|
||||||
// Normalize email: trim whitespace and lowercase for consistent matching
|
// Check if email already exists
|
||||||
let email = body.email.trim().to_lowercase();
|
let existing = sqlx::query_scalar::<_, String>("SELECT id FROM users WHERE email = ?")
|
||||||
let display_name = body.display_name.trim().to_string();
|
.bind(&body.email)
|
||||||
|
|
||||||
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)
|
.fetch_optional(&state.db)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
@ -48,49 +36,43 @@ pub async fn register(
|
|||||||
|
|
||||||
sqlx::query("INSERT INTO users (id, email, display_name, password_hash) VALUES (?, ?, ?, ?)")
|
sqlx::query("INSERT INTO users (id, email, display_name, password_hash) VALUES (?, ?, ?, ?)")
|
||||||
.bind(&user_id)
|
.bind(&user_id)
|
||||||
.bind(&email)
|
.bind(&body.email)
|
||||||
.bind(&display_name)
|
.bind(&body.display_name)
|
||||||
.bind(&password_hash)
|
.bind(&password_hash)
|
||||||
.execute(&state.db)
|
.execute(&state.db)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
let token = create_token(&user_id, &email, &display_name, &state.jwt_secret)
|
let token = create_token(&user_id, &body.email, &body.display_name, &state.jwt_secret)
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
Ok(Json(AuthResponse {
|
Ok(Json(AuthResponse {
|
||||||
token,
|
token,
|
||||||
user: UserPublic {
|
user: UserPublic {
|
||||||
id: user_id,
|
id: user_id,
|
||||||
email: models::public_email(&email),
|
email: body.email,
|
||||||
display_name,
|
display_name: body.display_name,
|
||||||
avatar_url: None,
|
|
||||||
nostr_pubkey: None,
|
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Authenticate an existing password-based account and return a fresh JWT.
|
|
||||||
pub async fn login(
|
pub async fn login(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
Json(body): Json<LoginRequest>,
|
Json(body): Json<LoginRequest>,
|
||||||
) -> Result<Json<AuthResponse>, (StatusCode, String)> {
|
) -> Result<Json<AuthResponse>, (StatusCode, String)> {
|
||||||
// Normalize email: trim whitespace and lowercase for case-insensitive matching
|
let user = sqlx::query_as::<_, (String, String, String, String)>(
|
||||||
let email = body.email.trim().to_lowercase();
|
"SELECT id, email, display_name, password_hash FROM users WHERE email = ?",
|
||||||
|
|
||||||
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(&email)
|
.bind(&body.email)
|
||||||
.fetch_optional(&state.db)
|
.fetch_optional(&state.db)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||||
.ok_or((StatusCode::UNAUTHORIZED, "Invalid credentials".into()))?;
|
.ok_or((StatusCode::UNAUTHORIZED, "Invalid credentials".into()))?;
|
||||||
|
|
||||||
let (user_id, email, display_name, hash, avatar_url) = user;
|
let (user_id, email, display_name, hash) = user;
|
||||||
|
|
||||||
let parsed_hash =
|
let parsed_hash = PasswordHash::new(&hash)
|
||||||
PasswordHash::new(&hash).map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
Argon2::default()
|
Argon2::default()
|
||||||
.verify_password(body.password.as_bytes(), &parsed_hash)
|
.verify_password(body.password.as_bytes(), &parsed_hash)
|
||||||
@ -103,33 +85,16 @@ pub async fn login(
|
|||||||
token,
|
token,
|
||||||
user: UserPublic {
|
user: UserPublic {
|
||||||
id: user_id,
|
id: user_id,
|
||||||
email: models::public_email(&email),
|
email,
|
||||||
display_name,
|
display_name,
|
||||||
avatar_url,
|
|
||||||
nostr_pubkey: None,
|
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return the caller's current public profile information.
|
pub async fn me(auth: AuthUser) -> Json<UserPublic> {
|
||||||
pub async fn me(
|
Json(UserPublic {
|
||||||
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,
|
id: auth.user_id,
|
||||||
email: models::public_email(&auth.email),
|
email: auth.email,
|
||||||
display_name: auth.display_name,
|
display_name: auth.display_name,
|
||||||
avatar_url: row.0,
|
})
|
||||||
nostr_pubkey: row.1,
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,11 +9,10 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
middleware::auth::AuthUser,
|
middleware::auth::AuthUser,
|
||||||
models::{CreateInviteRequest, NostrInviteRequest},
|
models::CreateInviteRequest,
|
||||||
AppState,
|
AppState,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Response payload for a newly created invite link.
|
|
||||||
#[derive(serde::Serialize)]
|
#[derive(serde::Serialize)]
|
||||||
pub struct InviteResponse {
|
pub struct InviteResponse {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
@ -21,7 +20,6 @@ pub struct InviteResponse {
|
|||||||
pub invite_url: String,
|
pub invite_url: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a one-time invite token for a room member to share.
|
|
||||||
pub async fn create_invite(
|
pub async fn create_invite(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
auth: AuthUser,
|
auth: AuthUser,
|
||||||
@ -48,17 +46,15 @@ pub async fn create_invite(
|
|||||||
.map(char::from)
|
.map(char::from)
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
sqlx::query(
|
sqlx::query("INSERT INTO invites (id, room_id, invited_by, email, token) VALUES (?, ?, ?, ?, ?)")
|
||||||
"INSERT INTO invites (id, room_id, invited_by, email, token) VALUES (?, ?, ?, ?, ?)",
|
.bind(&invite_id)
|
||||||
)
|
.bind(&body.room_id)
|
||||||
.bind(&invite_id)
|
.bind(&auth.user_id)
|
||||||
.bind(&body.room_id)
|
.bind(&body.email)
|
||||||
.bind(&auth.user_id)
|
.bind(&token)
|
||||||
.bind(&body.email)
|
.execute(&state.db)
|
||||||
.bind(&token)
|
.await
|
||||||
.execute(&state.db)
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
.await
|
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
|
||||||
|
|
||||||
Ok(Json(InviteResponse {
|
Ok(Json(InviteResponse {
|
||||||
id: invite_id,
|
id: invite_id,
|
||||||
@ -67,13 +63,11 @@ pub async fn create_invite(
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Response payload returned after consuming an invite.
|
|
||||||
#[derive(serde::Serialize)]
|
#[derive(serde::Serialize)]
|
||||||
pub struct AcceptInviteResponse {
|
pub struct AcceptInviteResponse {
|
||||||
pub room_id: String,
|
pub room_id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Consume an invite token and add the caller to the room.
|
|
||||||
pub async fn accept_invite(
|
pub async fn accept_invite(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
auth: AuthUser,
|
auth: AuthUser,
|
||||||
@ -95,12 +89,13 @@ pub async fn accept_invite(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verify room is not deleted
|
// Verify room is not deleted
|
||||||
let room_active =
|
let room_active = sqlx::query_scalar::<_, String>(
|
||||||
sqlx::query_scalar::<_, String>("SELECT id FROM rooms WHERE id = ? AND deleted_at IS NULL")
|
"SELECT id FROM rooms WHERE id = ? AND deleted_at IS NULL",
|
||||||
.bind(&room_id)
|
)
|
||||||
.fetch_optional(&state.db)
|
.bind(&room_id)
|
||||||
.await
|
.fetch_optional(&state.db)
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
if room_active.is_none() {
|
if room_active.is_none() {
|
||||||
return Err((StatusCode::GONE, "This room has been deleted".into()));
|
return Err((StatusCode::GONE, "This room has been deleted".into()));
|
||||||
@ -123,80 +118,3 @@ pub async fn accept_invite(
|
|||||||
|
|
||||||
Ok(Json(AcceptInviteResponse { room_id }))
|
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,
|
|
||||||
})),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,13 +1,5 @@
|
|||||||
//! 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 auth;
|
||||||
pub mod invites;
|
pub mod invites;
|
||||||
pub mod models;
|
pub mod models;
|
||||||
pub mod nostr_auth;
|
|
||||||
pub mod profile;
|
|
||||||
pub mod rooms;
|
pub mod rooms;
|
||||||
pub mod upload;
|
|
||||||
pub mod ws;
|
pub mod ws;
|
||||||
|
|||||||
@ -1,16 +1,19 @@
|
|||||||
use axum::{extract::State, http::StatusCode, Json};
|
use axum::{
|
||||||
|
extract::State,
|
||||||
|
http::StatusCode,
|
||||||
|
Json,
|
||||||
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::OnceCell;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
use tokio::sync::OnceCell;
|
|
||||||
|
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
|
|
||||||
/// Cached model list with expiry.
|
/// Cached model list with expiry.
|
||||||
static MODEL_CACHE: OnceCell<Mutex<CachedModels>> = OnceCell::const_new();
|
static MODEL_CACHE: OnceCell<Mutex<CachedModels>> = OnceCell::const_new();
|
||||||
|
|
||||||
/// Process-wide cache for the OpenRouter model catalog.
|
|
||||||
struct CachedModels {
|
struct CachedModels {
|
||||||
models: Vec<ModelInfo>,
|
models: Vec<ModelInfo>,
|
||||||
fetched_at: Instant,
|
fetched_at: Instant,
|
||||||
@ -18,7 +21,6 @@ struct CachedModels {
|
|||||||
|
|
||||||
const CACHE_TTL: Duration = Duration::from_secs(60 * 30); // 30 minutes
|
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)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
pub struct ModelInfo {
|
pub struct ModelInfo {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
@ -26,7 +28,6 @@ pub struct ModelInfo {
|
|||||||
pub context_length: Option<u64>,
|
pub context_length: Option<u64>,
|
||||||
pub pricing_prompt: Option<String>,
|
pub pricing_prompt: Option<String>,
|
||||||
pub pricing_completion: Option<String>,
|
pub pricing_completion: Option<String>,
|
||||||
pub supports_vision: bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@ -40,7 +41,6 @@ struct OpenRouterModel {
|
|||||||
name: String,
|
name: String,
|
||||||
context_length: Option<u64>,
|
context_length: Option<u64>,
|
||||||
pricing: Option<OpenRouterPricing>,
|
pricing: Option<OpenRouterPricing>,
|
||||||
architecture: Option<OpenRouterArchitecture>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@ -49,15 +49,6 @@ struct OpenRouterPricing {
|
|||||||
completion: Option<String>,
|
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> {
|
async fn fetch_models(api_key: &str) -> Result<Vec<ModelInfo>, String> {
|
||||||
let client = reqwest::Client::new();
|
let client = reqwest::Client::new();
|
||||||
|
|
||||||
@ -84,19 +75,12 @@ async fn fetch_models(api_key: &str) -> Result<Vec<ModelInfo>, String> {
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|m| {
|
.map(|m| {
|
||||||
let pricing = m.pricing.as_ref();
|
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 {
|
ModelInfo {
|
||||||
id: m.id,
|
id: m.id,
|
||||||
name: m.name,
|
name: m.name,
|
||||||
context_length: m.context_length,
|
context_length: m.context_length,
|
||||||
pricing_prompt: pricing.and_then(|p| p.prompt.clone()),
|
pricing_prompt: pricing.and_then(|p| p.prompt.clone()),
|
||||||
pricing_completion: pricing.and_then(|p| p.completion.clone()),
|
pricing_completion: pricing.and_then(|p| p.completion.clone()),
|
||||||
supports_vision,
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
@ -105,7 +89,6 @@ async fn fetch_models(api_key: &str) -> Result<Vec<ModelInfo>, String> {
|
|||||||
Ok(models)
|
Ok(models)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return the cached OpenRouter model list, refreshing it when the cache expires.
|
|
||||||
pub async fn list_models(
|
pub async fn list_models(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
) -> Result<Json<Vec<ModelInfo>>, (StatusCode, String)> {
|
) -> Result<Json<Vec<ModelInfo>>, (StatusCode, String)> {
|
||||||
|
|||||||
@ -1,182 +0,0 @@
|
|||||||
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),
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
@ -1,203 +0,0 @@
|
|||||||
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,
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
@ -8,13 +8,10 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
middleware::auth::AuthUser,
|
middleware::auth::AuthUser,
|
||||||
models::{
|
models::{CreateRoomRequest, Message, MessagePayload, PaginationParams, Room, RoomResponse, UserPublic},
|
||||||
self, CreateRoomRequest, MessagePayload, PaginationParams, Room, RoomResponse, UserPublic,
|
|
||||||
},
|
|
||||||
AppState,
|
AppState,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Create a room, persist it, and add the creator as the first member.
|
|
||||||
pub async fn create_room(
|
pub async fn create_room(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
auth: AuthUser,
|
auth: AuthUser,
|
||||||
@ -55,15 +52,12 @@ pub async fn create_room(
|
|||||||
created_at: chrono::Utc::now().to_rfc3339(),
|
created_at: chrono::Utc::now().to_rfc3339(),
|
||||||
members: vec![UserPublic {
|
members: vec![UserPublic {
|
||||||
id: auth.user_id,
|
id: auth.user_id,
|
||||||
email: models::public_email(&auth.email),
|
email: auth.email,
|
||||||
display_name: auth.display_name,
|
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(
|
pub async fn list_rooms(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
auth: AuthUser,
|
auth: AuthUser,
|
||||||
@ -78,8 +72,8 @@ pub async fn list_rooms(
|
|||||||
|
|
||||||
let mut result = Vec::new();
|
let mut result = Vec::new();
|
||||||
for room in rooms {
|
for room in rooms {
|
||||||
let members = sqlx::query_as::<_, (String, String, String, Option<String>, Option<String>)>(
|
let members = sqlx::query_as::<_, (String, String, 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 = ?",
|
"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 = ?",
|
||||||
)
|
)
|
||||||
.bind(&room.id)
|
.bind(&room.id)
|
||||||
.fetch_all(&state.db)
|
.fetch_all(&state.db)
|
||||||
@ -97,15 +91,11 @@ pub async fn list_rooms(
|
|||||||
created_at: room.created_at,
|
created_at: room.created_at,
|
||||||
members: members
|
members: members
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(
|
.map(|(id, email, display_name)| UserPublic {
|
||||||
|(id, email, display_name, avatar_url, nostr_pubkey)| UserPublic {
|
id,
|
||||||
id,
|
email,
|
||||||
email: models::public_email(&email),
|
display_name,
|
||||||
display_name,
|
})
|
||||||
avatar_url,
|
|
||||||
nostr_pubkey,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.collect(),
|
.collect(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -113,7 +103,6 @@ pub async fn list_rooms(
|
|||||||
Ok(Json(result))
|
Ok(Json(result))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return details for a single room after verifying the caller is a member.
|
|
||||||
pub async fn get_room(
|
pub async fn get_room(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
auth: AuthUser,
|
auth: AuthUser,
|
||||||
@ -140,8 +129,8 @@ pub async fn get_room(
|
|||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||||
.ok_or((StatusCode::NOT_FOUND, "Room not found".into()))?;
|
.ok_or((StatusCode::NOT_FOUND, "Room not found".into()))?;
|
||||||
|
|
||||||
let members = sqlx::query_as::<_, (String, String, String, Option<String>, Option<String>)>(
|
let members = sqlx::query_as::<_, (String, String, 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 = ?",
|
"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 = ?",
|
||||||
)
|
)
|
||||||
.bind(&room_id)
|
.bind(&room_id)
|
||||||
.fetch_all(&state.db)
|
.fetch_all(&state.db)
|
||||||
@ -159,20 +148,15 @@ pub async fn get_room(
|
|||||||
created_at: room.created_at,
|
created_at: room.created_at,
|
||||||
members: members
|
members: members
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(
|
.map(|(id, email, display_name)| UserPublic {
|
||||||
|(id, email, display_name, avatar_url, nostr_pubkey)| UserPublic {
|
id,
|
||||||
id,
|
email,
|
||||||
email: models::public_email(&email),
|
display_name,
|
||||||
display_name,
|
})
|
||||||
avatar_url,
|
|
||||||
nostr_pubkey,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.collect(),
|
.collect(),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return paginated message history for a room the caller can access.
|
|
||||||
pub async fn get_messages(
|
pub async fn get_messages(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
auth: AuthUser,
|
auth: AuthUser,
|
||||||
@ -193,12 +177,9 @@ pub async fn get_messages(
|
|||||||
return Err((StatusCode::FORBIDDEN, "Not a member of this room".into()));
|
return Err((StatusCode::FORBIDDEN, "Not a member of this room".into()));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Query messages with user email + avatar_url via LEFT JOIN
|
let messages = if let Some(before) = ¶ms.before {
|
||||||
let rows = if let Some(before) = ¶ms.before {
|
sqlx::query_as::<_, Message>(
|
||||||
sqlx::query_as::<_, (String, String, String, String, String, String, bool, String, Option<String>, Option<String>, Option<String>, Option<String>, Option<String>)>(
|
"SELECT * FROM messages WHERE room_id = ? AND created_at < ? ORDER BY created_at DESC LIMIT ?",
|
||||||
"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(&room_id)
|
||||||
.bind(before)
|
.bind(before)
|
||||||
@ -206,10 +187,8 @@ pub async fn get_messages(
|
|||||||
.fetch_all(&state.db)
|
.fetch_all(&state.db)
|
||||||
.await
|
.await
|
||||||
} else {
|
} else {
|
||||||
sqlx::query_as::<_, (String, String, String, String, String, String, bool, String, Option<String>, Option<String>, Option<String>, Option<String>, Option<String>)>(
|
sqlx::query_as::<_, Message>(
|
||||||
"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 \
|
"SELECT * FROM messages WHERE room_id = ? ORDER BY created_at DESC LIMIT ?",
|
||||||
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(&room_id)
|
||||||
.bind(params.limit)
|
.bind(params.limit)
|
||||||
@ -218,98 +197,41 @@ pub async fn get_messages(
|
|||||||
}
|
}
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
// The SQL query reads newest-first for efficient pagination, but clients
|
let payloads: Vec<MessagePayload> = messages
|
||||||
// render chat oldest-to-newest, so reverse the rows before serializing.
|
|
||||||
let payloads: Vec<MessagePayload> = rows
|
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.rev()
|
.rev()
|
||||||
.map(
|
.map(|m| {
|
||||||
|(
|
let ai_meta = m.ai_meta
|
||||||
id,
|
.as_deref()
|
||||||
room_id,
|
.and_then(|s| serde_json::from_str::<crate::models::AiMeta>(s).ok());
|
||||||
sender_id,
|
MessagePayload {
|
||||||
sender_name,
|
id: m.id,
|
||||||
content,
|
room_id: m.room_id,
|
||||||
mentions,
|
sender_id: m.sender_id,
|
||||||
is_ai,
|
sender_name: m.sender_name,
|
||||||
created_at,
|
content: m.content,
|
||||||
ai_meta_str,
|
mentions: serde_json::from_str(&m.mentions).unwrap_or_default(),
|
||||||
image_url,
|
is_ai: m.is_ai,
|
||||||
email,
|
created_at: m.created_at,
|
||||||
avatar_url,
|
ai_meta,
|
||||||
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,
|
|
||||||
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();
|
.collect();
|
||||||
|
|
||||||
Ok(Json(payloads))
|
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(
|
pub async fn join_room(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
auth: AuthUser,
|
auth: AuthUser,
|
||||||
Path(room_id): Path<String>,
|
Path(room_id): Path<String>,
|
||||||
) -> Result<StatusCode, (StatusCode, String)> {
|
) -> Result<StatusCode, (StatusCode, String)> {
|
||||||
// Check room exists
|
// Check room exists
|
||||||
let room_exists =
|
let room_exists = sqlx::query_scalar::<_, String>("SELECT id FROM rooms WHERE id = ? AND deleted_at IS NULL")
|
||||||
sqlx::query_scalar::<_, String>("SELECT id FROM rooms WHERE id = ? AND deleted_at IS NULL")
|
.bind(&room_id)
|
||||||
.bind(&room_id)
|
.fetch_optional(&state.db)
|
||||||
.fetch_optional(&state.db)
|
.await
|
||||||
.await
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
|
||||||
|
|
||||||
if room_exists.is_none() {
|
if room_exists.is_none() {
|
||||||
return Err((StatusCode::NOT_FOUND, "Room not found".into()));
|
return Err((StatusCode::NOT_FOUND, "Room not found".into()));
|
||||||
@ -325,7 +247,6 @@ pub async fn join_room(
|
|||||||
Ok(StatusCode::OK)
|
Ok(StatusCode::OK)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Soft-delete a room and broadcast the deletion event to connected members.
|
|
||||||
pub async fn delete_room(
|
pub async fn delete_room(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
auth: AuthUser,
|
auth: AuthUser,
|
||||||
@ -340,10 +261,7 @@ pub async fn delete_room(
|
|||||||
.ok_or((StatusCode::NOT_FOUND, "Room not found".into()))?;
|
.ok_or((StatusCode::NOT_FOUND, "Room not found".into()))?;
|
||||||
|
|
||||||
if room.created_by != auth.user_id {
|
if room.created_by != auth.user_id {
|
||||||
return Err((
|
return Err((StatusCode::FORBIDDEN, "Only the room creator can delete this room".into()));
|
||||||
StatusCode::FORBIDDEN,
|
|
||||||
"Only the room creator can delete this room".into(),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Soft-delete
|
// Soft-delete
|
||||||
@ -364,7 +282,6 @@ pub async fn delete_room(
|
|||||||
Ok(StatusCode::OK)
|
Ok(StatusCode::OK)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Permanently remove all messages from a room without deleting the room itself.
|
|
||||||
pub async fn clear_room(
|
pub async fn clear_room(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
auth: AuthUser,
|
auth: AuthUser,
|
||||||
@ -379,10 +296,7 @@ pub async fn clear_room(
|
|||||||
.ok_or((StatusCode::NOT_FOUND, "Room not found".into()))?;
|
.ok_or((StatusCode::NOT_FOUND, "Room not found".into()))?;
|
||||||
|
|
||||||
if room.created_by != auth.user_id {
|
if room.created_by != auth.user_id {
|
||||||
return Err((
|
return Err((StatusCode::FORBIDDEN, "Only the room creator can clear messages".into()));
|
||||||
StatusCode::FORBIDDEN,
|
|
||||||
"Only the room creator can clear messages".into(),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hard-delete all messages
|
// Hard-delete all messages
|
||||||
|
|||||||
@ -1,64 +0,0 @@
|
|||||||
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()))
|
|
||||||
}
|
|
||||||
@ -1,9 +1,3 @@
|
|||||||
//! 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::{
|
use axum::{
|
||||||
extract::{
|
extract::{
|
||||||
ws::{Message, WebSocket},
|
ws::{Message, WebSocket},
|
||||||
@ -18,7 +12,7 @@ use uuid::Uuid;
|
|||||||
use crate::{
|
use crate::{
|
||||||
middleware::auth::decode_token,
|
middleware::auth::decode_token,
|
||||||
models::{BroadcastEvent, MessagePayload, WsClientMessage, WsServerMessage},
|
models::{BroadcastEvent, MessagePayload, WsClientMessage, WsServerMessage},
|
||||||
services::{fetch, openrouter, search},
|
services::{brave, fetch, openrouter},
|
||||||
AppState,
|
AppState,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -30,7 +24,6 @@ pub struct WsQuery {
|
|||||||
token: String,
|
token: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Upgrade an authenticated request into a WebSocket connection.
|
|
||||||
pub async fn ws_handler(
|
pub async fn ws_handler(
|
||||||
ws: WebSocketUpgrade,
|
ws: WebSocketUpgrade,
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
@ -44,19 +37,10 @@ pub async fn ws_handler(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.on_upgrade(move |socket| {
|
ws.on_upgrade(move |socket| handle_socket(socket, state, claims.sub, claims.display_name))
|
||||||
handle_socket(socket, state, claims.sub, claims.display_name, claims.email)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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) {
|
||||||
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 ws_tx, mut ws_rx) = socket.split();
|
||||||
let mut broadcast_rx = state.tx.subscribe();
|
let mut broadcast_rx = state.tx.subscribe();
|
||||||
|
|
||||||
@ -66,8 +50,7 @@ async fn handle_socket(
|
|||||||
|
|
||||||
let rooms_clone = subscribed_rooms.clone();
|
let rooms_clone = subscribed_rooms.clone();
|
||||||
|
|
||||||
// Task 1: forward room events from the shared broadcast channel into this
|
// Task: forward broadcast events to this client
|
||||||
// specific socket, but only for rooms the browser subscribed to.
|
|
||||||
let mut send_task = tokio::spawn(async move {
|
let mut send_task = tokio::spawn(async move {
|
||||||
loop {
|
loop {
|
||||||
match broadcast_rx.recv().await {
|
match broadcast_rx.recv().await {
|
||||||
@ -95,11 +78,9 @@ async fn handle_socket(
|
|||||||
let state_clone = state.clone();
|
let state_clone = state.clone();
|
||||||
let user_id_clone = user_id.clone();
|
let user_id_clone = user_id.clone();
|
||||||
let display_name_clone = display_name.clone();
|
let display_name_clone = display_name.clone();
|
||||||
let email_clone = email.clone();
|
|
||||||
let rooms_clone2 = subscribed_rooms.clone();
|
let rooms_clone2 = subscribed_rooms.clone();
|
||||||
|
|
||||||
// Task 2: receive commands from the browser and translate them into
|
// Task: receive messages from client
|
||||||
// database writes, broadcasts, or AI work.
|
|
||||||
let mut recv_task = tokio::spawn(async move {
|
let mut recv_task = tokio::spawn(async move {
|
||||||
while let Some(Ok(msg)) = ws_rx.next().await {
|
while let Some(Ok(msg)) = ws_rx.next().await {
|
||||||
let text = match msg {
|
let text = match msg {
|
||||||
@ -140,18 +121,15 @@ async fn handle_socket(
|
|||||||
room_id,
|
room_id,
|
||||||
content,
|
content,
|
||||||
mentions,
|
mentions,
|
||||||
image_url,
|
|
||||||
} => {
|
} => {
|
||||||
tracing::info!("User {} sending message to room {}", user_id_clone, room_id);
|
tracing::info!("User {} sending message to room {}", user_id_clone, room_id);
|
||||||
handle_send_message(
|
handle_send_message(
|
||||||
&state_clone,
|
&state_clone,
|
||||||
&user_id_clone,
|
&user_id_clone,
|
||||||
&display_name_clone,
|
&display_name_clone,
|
||||||
&email_clone,
|
|
||||||
&room_id,
|
&room_id,
|
||||||
&content,
|
&content,
|
||||||
&mentions,
|
&mentions,
|
||||||
image_url.as_deref(),
|
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
@ -159,7 +137,7 @@ async fn handle_socket(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// If either half of the connection ends, stop the companion task too.
|
// Wait for either task to finish, then abort the other
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
_ = &mut send_task => recv_task.abort(),
|
_ = &mut send_task => recv_task.abort(),
|
||||||
_ = &mut recv_task => send_task.abort(),
|
_ = &mut recv_task => send_task.abort(),
|
||||||
@ -168,27 +146,21 @@ async fn handle_socket(
|
|||||||
tracing::info!("WebSocket disconnected: {}", user_id);
|
tracing::info!("WebSocket disconnected: {}", user_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Persist a user message, broadcast it, and optionally generate an AI reply.
|
|
||||||
async fn handle_send_message(
|
async fn handle_send_message(
|
||||||
state: &Arc<AppState>,
|
state: &Arc<AppState>,
|
||||||
user_id: &str,
|
user_id: &str,
|
||||||
display_name: &str,
|
display_name: &str,
|
||||||
email: &str,
|
|
||||||
room_id: &str,
|
room_id: &str,
|
||||||
content: &str,
|
content: &str,
|
||||||
mentions: &[String],
|
mentions: &[String],
|
||||||
image_url: Option<&str>,
|
|
||||||
) {
|
) {
|
||||||
let msg_id = Uuid::new_v4().to_string();
|
let msg_id = Uuid::new_v4().to_string();
|
||||||
let mentions_json = serde_json::to_string(mentions).unwrap_or_else(|_| "[]".to_string());
|
let mentions_json = serde_json::to_string(mentions).unwrap_or_else(|_| "[]".to_string());
|
||||||
let now = chrono::Utc::now().to_rfc3339();
|
let now = chrono::Utc::now().to_rfc3339();
|
||||||
|
|
||||||
// Compute integrity hash from timestamp + content
|
// Store in database
|
||||||
let hash = crate::models::message_hash(&now, content);
|
|
||||||
|
|
||||||
// Store in database (with image_url)
|
|
||||||
let _ = sqlx::query(
|
let _ = sqlx::query(
|
||||||
"INSERT INTO messages (id, room_id, sender_id, sender_name, content, mentions, is_ai, created_at, image_url, hash) VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?)",
|
"INSERT INTO messages (id, room_id, sender_id, sender_name, content, mentions, is_ai, created_at) VALUES (?, ?, ?, ?, ?, ?, 0, ?)",
|
||||||
)
|
)
|
||||||
.bind(&msg_id)
|
.bind(&msg_id)
|
||||||
.bind(room_id)
|
.bind(room_id)
|
||||||
@ -197,21 +169,9 @@ async fn handle_send_message(
|
|||||||
.bind(content)
|
.bind(content)
|
||||||
.bind(&mentions_json)
|
.bind(&mentions_json)
|
||||||
.bind(&now)
|
.bind(&now)
|
||||||
.bind(image_url)
|
|
||||||
.bind(&hash)
|
|
||||||
.execute(&state.db)
|
.execute(&state.db)
|
||||||
.await;
|
.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
|
// Broadcast human message
|
||||||
let payload = MessagePayload {
|
let payload = MessagePayload {
|
||||||
id: msg_id,
|
id: msg_id,
|
||||||
@ -223,19 +183,16 @@ async fn handle_send_message(
|
|||||||
is_ai: false,
|
is_ai: false,
|
||||||
created_at: now,
|
created_at: now,
|
||||||
ai_meta: None,
|
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 {
|
let _ = state.tx.send(BroadcastEvent {
|
||||||
room_id: room_id.to_string(),
|
room_id: room_id.to_string(),
|
||||||
message: WsServerMessage::NewMessage { message: payload },
|
message: WsServerMessage::NewMessage {
|
||||||
|
message: payload,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// The AI only replies when explicitly mentioned or when the room is set to
|
// Check if AI should respond
|
||||||
// auto-reply to every message.
|
|
||||||
let ai_user_id = "ai-assistant";
|
let ai_user_id = "ai-assistant";
|
||||||
let should_respond = mentions.contains(&ai_user_id.to_string());
|
let should_respond = mentions.contains(&ai_user_id.to_string());
|
||||||
|
|
||||||
@ -264,26 +221,16 @@ async fn handle_send_message(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fetch recent history (including image_url for multimodal support)
|
// Fetch recent history
|
||||||
let recent_messages = sqlx::query_as::<_, (String, String, bool, Option<String>)>(
|
let recent_messages = sqlx::query_as::<_, (String, String, bool)>(
|
||||||
"SELECT sender_name, content, is_ai, image_url FROM messages WHERE room_id = ? ORDER BY created_at DESC LIMIT 50",
|
"SELECT sender_name, content, is_ai FROM messages WHERE room_id = ? ORDER BY created_at DESC LIMIT 50",
|
||||||
)
|
)
|
||||||
.bind(room_id)
|
.bind(room_id)
|
||||||
.fetch_all(&state.db)
|
.fetch_all(&state.db)
|
||||||
.await
|
.await
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
// OpenRouter accepts image inputs as data URLs, so local uploads need to be
|
let history: Vec<(String, String, bool)> = recent_messages.into_iter().rev().collect();
|
||||||
// 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);
|
let mut chat_history = openrouter::build_chat_history(&system_prompt, &history);
|
||||||
|
|
||||||
// Build tools for AI
|
// Build tools for AI
|
||||||
@ -292,8 +239,7 @@ async fn handle_send_message(
|
|||||||
// Pre-generate AI message ID so we can reference it in stream chunks
|
// Pre-generate AI message ID so we can reference it in stream chunks
|
||||||
let ai_msg_id = Uuid::new_v4().to_string();
|
let ai_msg_id = Uuid::new_v4().to_string();
|
||||||
|
|
||||||
// Run the AI in a loop because the model may first request tools, then need
|
// Call OpenRouter with tool loop — uses streaming for all rounds
|
||||||
// follow-up rounds after those tool results are added to history.
|
|
||||||
let mut total_prompt_tokens: u32 = 0;
|
let mut total_prompt_tokens: u32 = 0;
|
||||||
let mut total_completion_tokens: u32 = 0;
|
let mut total_completion_tokens: u32 = 0;
|
||||||
let mut total_response_ms: u64 = 0;
|
let mut total_response_ms: u64 = 0;
|
||||||
@ -334,24 +280,16 @@ async fn handle_send_message(
|
|||||||
tracing::info!(
|
tracing::info!(
|
||||||
"AI requesting tool calls (round {}): {:?}",
|
"AI requesting tool calls (round {}): {:?}",
|
||||||
round + 1,
|
round + 1,
|
||||||
assistant_msg
|
assistant_msg.tool_calls.as_ref().map(|tc| tc.iter().map(|t| &t.function.name).collect::<Vec<_>>())
|
||||||
.tool_calls
|
|
||||||
.as_ref()
|
|
||||||
.map(|tc| tc.iter().map(|t| &t.function.name).collect::<Vec<_>>())
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Preserve the assistant tool-call message so the next round
|
// Add the assistant's tool-call message to history
|
||||||
// has the same context the model produced.
|
|
||||||
let tool_calls = assistant_msg.tool_calls.clone().unwrap_or_default();
|
let tool_calls = assistant_msg.tool_calls.clone().unwrap_or_default();
|
||||||
chat_history.push(assistant_msg);
|
chat_history.push(assistant_msg);
|
||||||
|
|
||||||
// Tool results are fed back into the conversation as
|
// Execute each tool call and add results
|
||||||
// synthetic `tool` messages, matching the upstream API.
|
|
||||||
for tool_call in &tool_calls {
|
for tool_call in &tool_calls {
|
||||||
let tool_input = extract_tool_input(
|
let tool_input = extract_tool_input(&tool_call.function.name, &tool_call.function.arguments);
|
||||||
&tool_call.function.name,
|
|
||||||
&tool_call.function.arguments,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Broadcast real-time tool usage event
|
// Broadcast real-time tool usage event
|
||||||
let _ = state.tx.send(BroadcastEvent {
|
let _ = state.tx.send(BroadcastEvent {
|
||||||
@ -366,9 +304,7 @@ async fn handle_send_message(
|
|||||||
let tool_result = execute_tool(
|
let tool_result = execute_tool(
|
||||||
&tool_call.function.name,
|
&tool_call.function.name,
|
||||||
&tool_call.function.arguments,
|
&tool_call.function.arguments,
|
||||||
state.search_provider,
|
&state.brave_api_key,
|
||||||
state.tavily_api_key.as_deref(),
|
|
||||||
state.brave_api_key.as_deref(),
|
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
@ -386,12 +322,12 @@ async fn handle_send_message(
|
|||||||
|
|
||||||
chat_history.push(openrouter::ChatMessage {
|
chat_history.push(openrouter::ChatMessage {
|
||||||
role: "tool".into(),
|
role: "tool".into(),
|
||||||
content: Some(openrouter::Content::Text(tool_result)),
|
content: Some(tool_result),
|
||||||
tool_calls: None,
|
tool_calls: None,
|
||||||
tool_call_id: Some(tool_call.id.clone()),
|
tool_call_id: Some(tool_call.id.clone()),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// Ask the model to continue now that tool output exists.
|
// Continue to next round (tool loop)
|
||||||
continue 'tool_loop;
|
continue 'tool_loop;
|
||||||
}
|
}
|
||||||
openrouter::StreamEvent::Done(stats) => {
|
openrouter::StreamEvent::Done(stats) => {
|
||||||
@ -411,12 +347,9 @@ async fn handle_send_message(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Guardrail: if the model never produced final prose, store a clear fallback
|
// If we exhausted all rounds without a text response, note it
|
||||||
// instead of leaving the client waiting indefinitely.
|
|
||||||
if ai_response.is_empty() && !had_error {
|
if ai_response.is_empty() && !had_error {
|
||||||
ai_response =
|
ai_response = "*I used several tools but couldn't formulate a final response. Please try again.*".to_string();
|
||||||
"*I used several tools but couldn't formulate a final response. Please try again.*"
|
|
||||||
.to_string();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Signal stream end so client can finalize rendering
|
// Signal stream end so client can finalize rendering
|
||||||
@ -451,11 +384,8 @@ async fn handle_send_message(
|
|||||||
// Serialize ai_meta for database storage
|
// Serialize ai_meta for database storage
|
||||||
let ai_meta_json = ai_meta.as_ref().and_then(|m| serde_json::to_string(m).ok());
|
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(
|
let _ = sqlx::query(
|
||||||
"INSERT INTO messages (id, room_id, sender_id, sender_name, content, mentions, is_ai, created_at, ai_meta, hash) VALUES (?, ?, ?, ?, ?, '[]', 1, ?, ?, ?)",
|
"INSERT INTO messages (id, room_id, sender_id, sender_name, content, mentions, is_ai, created_at, ai_meta) VALUES (?, ?, ?, ?, ?, '[]', 1, ?, ?)",
|
||||||
)
|
)
|
||||||
.bind(&ai_msg_id)
|
.bind(&ai_msg_id)
|
||||||
.bind(room_id)
|
.bind(room_id)
|
||||||
@ -464,7 +394,6 @@ async fn handle_send_message(
|
|||||||
.bind(&ai_response)
|
.bind(&ai_response)
|
||||||
.bind(&ai_now)
|
.bind(&ai_now)
|
||||||
.bind(&ai_meta_json)
|
.bind(&ai_meta_json)
|
||||||
.bind(&ai_hash)
|
|
||||||
.execute(&state.db)
|
.execute(&state.db)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
@ -479,10 +408,6 @@ async fn handle_send_message(
|
|||||||
is_ai: true,
|
is_ai: true,
|
||||||
created_at: ai_now,
|
created_at: ai_now,
|
||||||
ai_meta,
|
ai_meta,
|
||||||
avatar_hash: String::new(),
|
|
||||||
avatar_url: None,
|
|
||||||
image_url: None,
|
|
||||||
hash: Some(ai_hash),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let _ = state.tx.send(BroadcastEvent {
|
let _ = state.tx.send(BroadcastEvent {
|
||||||
@ -493,49 +418,20 @@ 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).
|
/// Extract a human-readable input string from tool arguments (for UI display).
|
||||||
fn extract_tool_input(tool_name: &str, arguments: &str) -> String {
|
fn extract_tool_input(tool_name: &str, arguments: &str) -> String {
|
||||||
let args: serde_json::Value = serde_json::from_str(arguments).unwrap_or_default();
|
let args: serde_json::Value = serde_json::from_str(arguments).unwrap_or_default();
|
||||||
match tool_name {
|
match tool_name {
|
||||||
"web_search" | "brave_search" => args["query"].as_str().unwrap_or("").to_string(),
|
"brave_search" => args["query"].as_str().unwrap_or("").to_string(),
|
||||||
"web_fetch" => args["url"].as_str().unwrap_or("").to_string(),
|
"web_fetch" => args["url"].as_str().unwrap_or("").to_string(),
|
||||||
_ => arguments.to_string(),
|
_ => arguments.to_string(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Execute a tool call by name, returning the result as a string.
|
/// Execute a tool call by name, returning the result as a string.
|
||||||
async fn execute_tool(
|
async fn execute_tool(name: &str, arguments: &str, brave_api_key: &str) -> String {
|
||||||
name: &str,
|
|
||||||
arguments: &str,
|
|
||||||
search_provider: search::SearchProvider,
|
|
||||||
tavily_api_key: Option<&str>,
|
|
||||||
brave_api_key: Option<&str>,
|
|
||||||
) -> String {
|
|
||||||
match name {
|
match name {
|
||||||
"web_search" | "brave_search" => {
|
"brave_search" => {
|
||||||
let args: serde_json::Value = serde_json::from_str(arguments).unwrap_or_default();
|
let args: serde_json::Value = serde_json::from_str(arguments).unwrap_or_default();
|
||||||
let query = args["query"].as_str().unwrap_or("").to_string();
|
let query = args["query"].as_str().unwrap_or("").to_string();
|
||||||
let count = args["count"].as_u64().unwrap_or(5) as u8;
|
let count = args["count"].as_u64().unwrap_or(5) as u8;
|
||||||
@ -544,16 +440,8 @@ async fn execute_tool(
|
|||||||
return "Error: search query is required".into();
|
return "Error: search query is required".into();
|
||||||
}
|
}
|
||||||
|
|
||||||
match search::search(
|
match brave::search(&query, brave_api_key, count).await {
|
||||||
search_provider,
|
Ok(results) => brave::format_results(&results),
|
||||||
&query,
|
|
||||||
tavily_api_key,
|
|
||||||
brave_api_key,
|
|
||||||
count,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(results) => search::format_results(&results),
|
|
||||||
Err(e) => format!("Search error: {}", e),
|
Err(e) => format!("Search error: {}", e),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,173 +1,23 @@
|
|||||||
//! 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 handlers;
|
||||||
mod middleware;
|
mod middleware;
|
||||||
mod models;
|
mod models;
|
||||||
mod services;
|
mod services;
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
routing::{get, post, put},
|
routing::{get, post},
|
||||||
Router,
|
Router,
|
||||||
};
|
};
|
||||||
use sqlx::sqlite::SqlitePoolOptions;
|
use sqlx::sqlite::SqlitePoolOptions;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::broadcast;
|
use tokio::sync::broadcast;
|
||||||
use tower_http::cors::{Any, CorsLayer};
|
use tower_http::cors::{Any, CorsLayer};
|
||||||
use tower_http::services::{ServeDir, ServeFile};
|
|
||||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
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 struct AppState {
|
||||||
pub db: sqlx::SqlitePool,
|
pub db: sqlx::SqlitePool,
|
||||||
pub jwt_secret: String,
|
pub jwt_secret: String,
|
||||||
pub openrouter_key: String,
|
pub openrouter_key: String,
|
||||||
pub search_provider: SearchProvider,
|
pub brave_api_key: String,
|
||||||
pub tavily_api_key: Option<String>,
|
|
||||||
pub brave_api_key: Option<String>,
|
|
||||||
pub tx: broadcast::Sender<models::BroadcastEvent>,
|
pub tx: broadcast::Sender<models::BroadcastEvent>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -182,30 +32,10 @@ async fn main() {
|
|||||||
.with(tracing_subscriber::fmt::layer())
|
.with(tracing_subscriber::fmt::layer())
|
||||||
.init();
|
.init();
|
||||||
|
|
||||||
// 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 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 jwt_secret = std::env::var("JWT_SECRET").unwrap_or_else(|_| "dev-secret-change-me".into());
|
||||||
let openrouter_key =
|
let openrouter_key = std::env::var("OPENROUTER_API_KEY").expect("OPENROUTER_API_KEY must be set");
|
||||||
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 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()
|
let db = SqlitePoolOptions::new()
|
||||||
.max_connections(5)
|
.max_connections(5)
|
||||||
@ -213,8 +43,7 @@ async fn main() {
|
|||||||
.await
|
.await
|
||||||
.expect("Failed to connect to database");
|
.expect("Failed to connect to database");
|
||||||
|
|
||||||
// Run migrations in order. Each one is written so startup can safely try it
|
// Run migrations
|
||||||
// again and skip work that already happened in an earlier run.
|
|
||||||
let migration_sql = include_str!("../migrations/001_init.sql");
|
let migration_sql = include_str!("../migrations/001_init.sql");
|
||||||
sqlx::raw_sql(migration_sql)
|
sqlx::raw_sql(migration_sql)
|
||||||
.execute(&db)
|
.execute(&db)
|
||||||
@ -251,80 +80,14 @@ async fn main() {
|
|||||||
Err(e) => panic!("Failed to run migration 004: {}", e),
|
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");
|
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 (tx, _rx) = broadcast::channel::<models::BroadcastEvent>(4096);
|
||||||
|
|
||||||
let state = Arc::new(AppState {
|
let state = Arc::new(AppState {
|
||||||
db,
|
db,
|
||||||
jwt_secret,
|
jwt_secret,
|
||||||
openrouter_key,
|
openrouter_key,
|
||||||
search_provider,
|
|
||||||
tavily_api_key,
|
|
||||||
brave_api_key,
|
brave_api_key,
|
||||||
tx,
|
tx,
|
||||||
});
|
});
|
||||||
@ -334,77 +97,27 @@ async fn main() {
|
|||||||
.allow_methods(Any)
|
.allow_methods(Any)
|
||||||
.allow_headers(Any);
|
.allow_headers(Any);
|
||||||
|
|
||||||
// Serve static files from client dist in production
|
let app = Router::new()
|
||||||
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
|
// Auth routes
|
||||||
.route("/api/auth/register", post(handlers::auth::register))
|
.route("/api/auth/register", post(handlers::auth::register))
|
||||||
.route("/api/auth/login", post(handlers::auth::login))
|
.route("/api/auth/login", post(handlers::auth::login))
|
||||||
.route("/api/auth/me", get(handlers::auth::me))
|
.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
|
// Room routes
|
||||||
.route(
|
.route("/api/rooms", get(handlers::rooms::list_rooms).post(handlers::rooms::create_room))
|
||||||
"/api/rooms",
|
.route("/api/rooms/:room_id", get(handlers::rooms::get_room).delete(handlers::rooms::delete_room))
|
||||||
get(handlers::rooms::list_rooms).post(handlers::rooms::create_room),
|
.route("/api/rooms/:room_id/messages", get(handlers::rooms::get_messages))
|
||||||
)
|
|
||||||
.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/join", post(handlers::rooms::join_room))
|
||||||
.route(
|
.route("/api/rooms/:room_id/clear", post(handlers::rooms::clear_room))
|
||||||
"/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
|
// Models
|
||||||
.route("/api/models", get(handlers::models::list_models))
|
.route("/api/models", get(handlers::models::list_models))
|
||||||
// Invite routes
|
// Invite routes
|
||||||
.route("/api/invites", post(handlers::invites::create_invite))
|
.route("/api/invites", post(handlers::invites::create_invite))
|
||||||
.route(
|
.route("/api/invites/:token/accept", post(handlers::invites::accept_invite))
|
||||||
"/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
|
// WebSocket
|
||||||
.route("/ws", get(handlers::ws::ws_handler))
|
.route("/ws", get(handlers::ws::ws_handler))
|
||||||
.layer(cors)
|
.layer(cors)
|
||||||
.with_state(state);
|
.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());
|
let addr = std::env::var("BIND_ADDR").unwrap_or_else(|_| "0.0.0.0:3001".into());
|
||||||
tracing::info!("Server starting on {}", addr);
|
tracing::info!("Server starting on {}", addr);
|
||||||
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
|
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
|
||||||
|
|||||||
@ -1,11 +1,14 @@
|
|||||||
use async_trait::async_trait;
|
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 jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use crate::{models::Claims, AppState};
|
use crate::{models::Claims, AppState};
|
||||||
|
|
||||||
/// Authenticated user information extracted from the bearer token.
|
/// Extract authenticated user from JWT in Authorization header
|
||||||
pub struct AuthUser {
|
pub struct AuthUser {
|
||||||
pub user_id: String,
|
pub user_id: String,
|
||||||
pub email: String,
|
pub email: String,
|
||||||
@ -16,15 +19,7 @@ pub struct AuthUser {
|
|||||||
impl FromRequestParts<Arc<AppState>> for AuthUser {
|
impl FromRequestParts<Arc<AppState>> for AuthUser {
|
||||||
type Rejection = axum::http::StatusCode;
|
type Rejection = axum::http::StatusCode;
|
||||||
|
|
||||||
/// Read the `Authorization: Bearer <token>` header and decode the JWT.
|
async fn from_request_parts(parts: &mut Parts, state: &Arc<AppState>) -> Result<Self, Self::Rejection> {
|
||||||
///
|
|
||||||
/// 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
|
let auth_header = parts
|
||||||
.headers
|
.headers
|
||||||
.get("Authorization")
|
.get("Authorization")
|
||||||
@ -46,16 +41,7 @@ impl FromRequestParts<Arc<AppState>> for AuthUser {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a signed JWT for a logged-in user.
|
pub fn create_token(user_id: &str, email: &str, display_name: &str, secret: &str) -> Result<String, jsonwebtoken::errors::Error> {
|
||||||
///
|
|
||||||
/// 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()
|
let expiration = chrono::Utc::now()
|
||||||
.checked_add_signed(chrono::Duration::days(7))
|
.checked_add_signed(chrono::Duration::days(7))
|
||||||
.unwrap()
|
.unwrap()
|
||||||
@ -75,7 +61,6 @@ pub fn create_token(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Decode and validate a previously issued JWT.
|
|
||||||
pub fn decode_token(token: &str, secret: &str) -> Result<Claims, jsonwebtoken::errors::Error> {
|
pub fn decode_token(token: &str, secret: &str) -> Result<Claims, jsonwebtoken::errors::Error> {
|
||||||
let token_data = decode::<Claims>(
|
let token_data = decode::<Claims>(
|
||||||
token,
|
token,
|
||||||
|
|||||||
@ -1,3 +1 @@
|
|||||||
//! Reusable request-processing layers shared across handlers.
|
|
||||||
|
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
|
|||||||
@ -1,14 +1,7 @@
|
|||||||
//! 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};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
// ── Database models ──
|
// ── Database models ──
|
||||||
|
|
||||||
/// Row from the `users` table.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||||
pub struct User {
|
pub struct User {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
@ -18,7 +11,6 @@ pub struct User {
|
|||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Row from the `rooms` table.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||||
pub struct Room {
|
pub struct Room {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
@ -32,7 +24,6 @@ pub struct Room {
|
|||||||
pub deleted_at: Option<String>,
|
pub deleted_at: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Row from the `messages` table.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||||
pub struct Message {
|
pub struct Message {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
@ -44,10 +35,8 @@ pub struct Message {
|
|||||||
pub is_ai: bool,
|
pub is_ai: bool,
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
pub ai_meta: Option<String>,
|
pub ai_meta: Option<String>,
|
||||||
pub hash: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Row from the `invites` table.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||||
pub struct Invite {
|
pub struct Invite {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
@ -61,7 +50,6 @@ pub struct Invite {
|
|||||||
|
|
||||||
// ── API request/response types ──
|
// ── API request/response types ──
|
||||||
|
|
||||||
/// JSON body expected by the registration endpoint.
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct RegisterRequest {
|
pub struct RegisterRequest {
|
||||||
pub email: String,
|
pub email: String,
|
||||||
@ -69,33 +57,25 @@ pub struct RegisterRequest {
|
|||||||
pub display_name: String,
|
pub display_name: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// JSON body expected by the login endpoint.
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct LoginRequest {
|
pub struct LoginRequest {
|
||||||
pub email: String,
|
pub email: String,
|
||||||
pub password: String,
|
pub password: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Standard auth response returned after login, registration, or profile update.
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
pub struct AuthResponse {
|
pub struct AuthResponse {
|
||||||
pub token: String,
|
pub token: String,
|
||||||
pub user: UserPublic,
|
pub user: UserPublic,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Public user data safe to return to any authenticated client.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct UserPublic {
|
pub struct UserPublic {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub email: String,
|
pub email: String,
|
||||||
pub display_name: 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)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct CreateRoomRequest {
|
pub struct CreateRoomRequest {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
@ -108,10 +88,10 @@ pub struct CreateRoomRequest {
|
|||||||
pub ai_name: String,
|
pub ai_name: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pick a friendly default AI display name when the creator does not specify one.
|
|
||||||
fn default_ai_name() -> String {
|
fn default_ai_name() -> String {
|
||||||
let names = [
|
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()
|
let idx = std::time::SystemTime::now()
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
@ -120,12 +100,10 @@ fn default_ai_name() -> String {
|
|||||||
names[idx].to_string()
|
names[idx].to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Default prompt that defines the AI assistant's behavior inside a room.
|
|
||||||
fn default_system_prompt() -> String {
|
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- **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()
|
"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()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Full room payload returned to the client, including current members.
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
pub struct RoomResponse {
|
pub struct RoomResponse {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
@ -139,7 +117,6 @@ pub struct RoomResponse {
|
|||||||
pub members: Vec<UserPublic>,
|
pub members: Vec<UserPublic>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// JSON body for an email-based room invite.
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct CreateInviteRequest {
|
pub struct CreateInviteRequest {
|
||||||
pub room_id: String,
|
pub room_id: String,
|
||||||
@ -148,7 +125,6 @@ pub struct CreateInviteRequest {
|
|||||||
|
|
||||||
// ── WebSocket event types ──
|
// ── WebSocket event types ──
|
||||||
|
|
||||||
/// Messages the browser can send over the WebSocket connection.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(tag = "type")]
|
#[serde(tag = "type")]
|
||||||
pub enum WsClientMessage {
|
pub enum WsClientMessage {
|
||||||
@ -158,8 +134,6 @@ pub enum WsClientMessage {
|
|||||||
content: String,
|
content: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
mentions: Vec<String>,
|
mentions: Vec<String>,
|
||||||
#[serde(default)]
|
|
||||||
image_url: Option<String>,
|
|
||||||
},
|
},
|
||||||
#[serde(rename = "join_room")]
|
#[serde(rename = "join_room")]
|
||||||
JoinRoom { room_id: String },
|
JoinRoom { room_id: String },
|
||||||
@ -167,14 +141,17 @@ pub enum WsClientMessage {
|
|||||||
Typing { room_id: String },
|
Typing { room_id: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Messages the server can push to browsers over the WebSocket connection.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(tag = "type")]
|
#[serde(tag = "type")]
|
||||||
pub enum WsServerMessage {
|
pub enum WsServerMessage {
|
||||||
#[serde(rename = "new_message")]
|
#[serde(rename = "new_message")]
|
||||||
NewMessage { message: MessagePayload },
|
NewMessage {
|
||||||
|
message: MessagePayload,
|
||||||
|
},
|
||||||
#[serde(rename = "ai_typing")]
|
#[serde(rename = "ai_typing")]
|
||||||
AiTyping { room_id: String },
|
AiTyping {
|
||||||
|
room_id: String,
|
||||||
|
},
|
||||||
#[serde(rename = "user_typing")]
|
#[serde(rename = "user_typing")]
|
||||||
UserTyping {
|
UserTyping {
|
||||||
room_id: String,
|
room_id: String,
|
||||||
@ -182,13 +159,21 @@ pub enum WsServerMessage {
|
|||||||
display_name: String,
|
display_name: String,
|
||||||
},
|
},
|
||||||
#[serde(rename = "error")]
|
#[serde(rename = "error")]
|
||||||
Error { message: String },
|
Error {
|
||||||
|
message: String,
|
||||||
|
},
|
||||||
#[serde(rename = "joined")]
|
#[serde(rename = "joined")]
|
||||||
Joined { room_id: String },
|
Joined {
|
||||||
|
room_id: String,
|
||||||
|
},
|
||||||
#[serde(rename = "room_deleted")]
|
#[serde(rename = "room_deleted")]
|
||||||
RoomDeleted { room_id: String },
|
RoomDeleted {
|
||||||
|
room_id: String,
|
||||||
|
},
|
||||||
#[serde(rename = "room_cleared")]
|
#[serde(rename = "room_cleared")]
|
||||||
RoomCleared { room_id: String },
|
RoomCleared {
|
||||||
|
room_id: String,
|
||||||
|
},
|
||||||
#[serde(rename = "ai_tool_usage")]
|
#[serde(rename = "ai_tool_usage")]
|
||||||
AiToolUsage {
|
AiToolUsage {
|
||||||
room_id: String,
|
room_id: String,
|
||||||
@ -202,10 +187,12 @@ pub enum WsServerMessage {
|
|||||||
delta: String,
|
delta: String,
|
||||||
},
|
},
|
||||||
#[serde(rename = "ai_stream_end")]
|
#[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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct MessagePayload {
|
pub struct MessagePayload {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
@ -218,34 +205,8 @@ pub struct MessagePayload {
|
|||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub ai_meta: Option<AiMeta>,
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct AiMeta {
|
pub struct AiMeta {
|
||||||
pub model: String,
|
pub model: String,
|
||||||
@ -257,7 +218,6 @@ pub struct AiMeta {
|
|||||||
pub tool_results: Option<Vec<ToolResult>>,
|
pub tool_results: Option<Vec<ToolResult>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One tool invocation performed while generating an AI answer.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct ToolResult {
|
pub struct ToolResult {
|
||||||
pub tool: String,
|
pub tool: String,
|
||||||
@ -267,7 +227,6 @@ pub struct ToolResult {
|
|||||||
|
|
||||||
// ── Broadcast event (internal channel) ──
|
// ── Broadcast event (internal channel) ──
|
||||||
|
|
||||||
/// Internal event sent through a Tokio broadcast channel to WebSocket tasks.
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct BroadcastEvent {
|
pub struct BroadcastEvent {
|
||||||
pub room_id: String,
|
pub room_id: String,
|
||||||
@ -276,10 +235,9 @@ pub struct BroadcastEvent {
|
|||||||
|
|
||||||
// ── JWT Claims ──
|
// ── JWT Claims ──
|
||||||
|
|
||||||
/// Claims stored inside the server-issued JWT.
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
pub struct Claims {
|
pub struct Claims {
|
||||||
pub sub: String, // user_id
|
pub sub: String, // user_id
|
||||||
pub email: String,
|
pub email: String,
|
||||||
pub display_name: String,
|
pub display_name: String,
|
||||||
pub exp: usize,
|
pub exp: usize,
|
||||||
@ -287,7 +245,6 @@ pub struct Claims {
|
|||||||
|
|
||||||
// ── Pagination ──
|
// ── Pagination ──
|
||||||
|
|
||||||
/// Common pagination parameters for message history endpoints.
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct PaginationParams {
|
pub struct PaginationParams {
|
||||||
#[serde(default = "default_limit")]
|
#[serde(default = "default_limit")]
|
||||||
@ -298,36 +255,3 @@ pub struct PaginationParams {
|
|||||||
fn default_limit() -> i64 {
|
fn default_limit() -> i64 {
|
||||||
50
|
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,
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,10 +1,7 @@
|
|||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
use crate::services::search::SearchResult;
|
|
||||||
|
|
||||||
const BRAVE_SEARCH_URL: &str = "https://api.search.brave.com/res/v1/web/search";
|
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)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct BraveResponse {
|
struct BraveResponse {
|
||||||
web: Option<BraveWebResults>,
|
web: Option<BraveWebResults>,
|
||||||
@ -26,9 +23,22 @@ struct BraveResult {
|
|||||||
extra_snippets: Option<Vec<String>>,
|
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.
|
/// Search the web using the Brave Search API.
|
||||||
/// Returns a list of simplified search results.
|
/// 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 count = count.clamp(1, 10);
|
||||||
|
|
||||||
let client = reqwest::Client::new();
|
let client = reqwest::Client::new();
|
||||||
@ -81,3 +91,21 @@ pub async fn search(query: &str, api_key: &str, count: u8) -> Result<Vec<SearchR
|
|||||||
|
|
||||||
Ok(results)
|
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
|
||||||
|
}
|
||||||
|
|||||||
@ -19,29 +19,9 @@ const STRIP_TAGS: &[&str] = &[
|
|||||||
|
|
||||||
/// Block-level tags that should produce newlines in text output.
|
/// Block-level tags that should produce newlines in text output.
|
||||||
const BLOCK_TAGS: &[&str] = &[
|
const BLOCK_TAGS: &[&str] = &[
|
||||||
"p",
|
"p", "div", "h1", "h2", "h3", "h4", "h5", "h6", "li", "br", "tr",
|
||||||
"div",
|
"blockquote", "pre", "section", "article", "main", "header",
|
||||||
"h1",
|
"dt", "dd", "figcaption", "table", "thead", "tbody",
|
||||||
"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.
|
/// Fetch a URL and extract its text content.
|
||||||
|
|||||||
@ -1,11 +1,3 @@
|
|||||||
//! 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 brave;
|
||||||
pub mod fetch;
|
pub mod fetch;
|
||||||
pub mod openrouter;
|
pub mod openrouter;
|
||||||
pub mod search;
|
|
||||||
pub mod tavily;
|
|
||||||
|
|||||||
@ -16,34 +16,11 @@ struct ChatRequest {
|
|||||||
stream: Option<bool>,
|
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)]
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
pub struct ChatMessage {
|
pub struct ChatMessage {
|
||||||
pub role: String,
|
pub role: String,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub content: Option<Content>,
|
pub content: Option<String>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub tool_calls: Option<Vec<ToolCall>>,
|
pub tool_calls: Option<Vec<ToolCall>>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
@ -149,13 +126,13 @@ pub struct CompletionStats {
|
|||||||
pub response_ms: u64,
|
pub response_ms: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build the tool definitions for web_search and web_fetch.
|
/// Build the tool definitions for brave_search and web_fetch.
|
||||||
pub fn build_tools() -> Vec<Tool> {
|
pub fn build_tools() -> Vec<Tool> {
|
||||||
vec![
|
vec![
|
||||||
Tool {
|
Tool {
|
||||||
r#type: "function".into(),
|
r#type: "function".into(),
|
||||||
function: ToolFunction {
|
function: ToolFunction {
|
||||||
name: "web_search".into(),
|
name: "brave_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(),
|
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!({
|
parameters: serde_json::json!({
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@ -235,9 +212,7 @@ pub async fn chat_completion_stream(
|
|||||||
{
|
{
|
||||||
Ok(r) => r,
|
Ok(r) => r,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let _ = tx
|
let _ = tx.send(StreamEvent::Error(format!("Request failed: {}", e))).await;
|
||||||
.send(StreamEvent::Error(format!("Request failed: {}", e)))
|
|
||||||
.await;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -245,12 +220,7 @@ pub async fn chat_completion_stream(
|
|||||||
if !response.status().is_success() {
|
if !response.status().is_success() {
|
||||||
let status = response.status();
|
let status = response.status();
|
||||||
let body = response.text().await.unwrap_or_default();
|
let body = response.text().await.unwrap_or_default();
|
||||||
let _ = tx
|
let _ = tx.send(StreamEvent::Error(format!("OpenRouter error {}: {}", status, body))).await;
|
||||||
.send(StreamEvent::Error(format!(
|
|
||||||
"OpenRouter error {}: {}",
|
|
||||||
status, body
|
|
||||||
)))
|
|
||||||
.await;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -271,9 +241,7 @@ pub async fn chat_completion_stream(
|
|||||||
let bytes = match chunk_result {
|
let bytes = match chunk_result {
|
||||||
Ok(b) => b,
|
Ok(b) => b,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let _ = tx
|
let _ = tx.send(StreamEvent::Error(format!("Stream error: {}", e))).await;
|
||||||
.send(StreamEvent::Error(format!("Stream error: {}", e)))
|
|
||||||
.await;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -347,10 +315,7 @@ pub async fn chat_completion_stream(
|
|||||||
tool_call_accum[idx].function.name.push_str(name);
|
tool_call_accum[idx].function.name.push_str(name);
|
||||||
}
|
}
|
||||||
if let Some(args) = &func.arguments {
|
if let Some(args) = &func.arguments {
|
||||||
tool_call_accum[idx]
|
tool_call_accum[idx].function.arguments.push_str(args);
|
||||||
.function
|
|
||||||
.arguments
|
|
||||||
.push_str(args);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -385,11 +350,7 @@ pub async fn chat_completion_stream(
|
|||||||
// AI requested tool calls
|
// AI requested tool calls
|
||||||
let assistant_msg = ChatMessage {
|
let assistant_msg = ChatMessage {
|
||||||
role: "assistant".into(),
|
role: "assistant".into(),
|
||||||
content: if full_content.is_empty() {
|
content: if full_content.is_empty() { None } else { Some(full_content) },
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(Content::Text(full_content))
|
|
||||||
},
|
|
||||||
tool_calls: Some(tool_call_accum),
|
tool_calls: Some(tool_call_accum),
|
||||||
tool_call_id: None,
|
tool_call_id: None,
|
||||||
};
|
};
|
||||||
@ -405,49 +366,29 @@ pub async fn chat_completion_stream(
|
|||||||
|
|
||||||
/// Build the message history for OpenRouter from stored messages.
|
/// Build the message history for OpenRouter from stored messages.
|
||||||
/// Includes the system prompt as the first message.
|
/// Includes the system prompt as the first message.
|
||||||
/// Messages with image data URLs will be sent as multimodal content.
|
|
||||||
pub fn build_chat_history(
|
pub fn build_chat_history(
|
||||||
system_prompt: &str,
|
system_prompt: &str,
|
||||||
messages: &[(String, String, bool, Option<String>)], // (sender_name, content, is_ai, image_data_url)
|
messages: &[(String, String, bool)], // (sender_name, content, is_ai)
|
||||||
) -> Vec<ChatMessage> {
|
) -> Vec<ChatMessage> {
|
||||||
let mut history = vec![ChatMessage {
|
let mut history = vec![ChatMessage {
|
||||||
role: "system".to_string(),
|
role: "system".to_string(),
|
||||||
content: Some(Content::Text(system_prompt.to_string())),
|
content: Some(system_prompt.to_string()),
|
||||||
tool_calls: None,
|
tool_calls: None,
|
||||||
tool_call_id: None,
|
tool_call_id: None,
|
||||||
}];
|
}];
|
||||||
|
|
||||||
for (sender_name, content, is_ai, image_data_url) in messages {
|
for (sender_name, content, is_ai) in messages {
|
||||||
if *is_ai {
|
if *is_ai {
|
||||||
history.push(ChatMessage {
|
history.push(ChatMessage {
|
||||||
role: "assistant".to_string(),
|
role: "assistant".to_string(),
|
||||||
content: Some(Content::Text(content.clone())),
|
content: Some(content.clone()),
|
||||||
tool_calls: None,
|
tool_calls: None,
|
||||||
tool_call_id: None,
|
tool_call_id: None,
|
||||||
});
|
});
|
||||||
} else {
|
} 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 {
|
history.push(ChatMessage {
|
||||||
role: "user".to_string(),
|
role: "user".to_string(),
|
||||||
content: Some(msg_content),
|
content: Some(format!("[{}]: {}", sender_name, content)),
|
||||||
tool_calls: None,
|
tool_calls: None,
|
||||||
tool_call_id: None,
|
tool_call_id: None,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,90 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@ -1,90 +0,0 @@
|
|||||||
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.
|
Before Width: | Height: | Size: 1.4 MiB |
Loading…
x
Reference in New Issue
Block a user