feat: complete GroupChat app with AI tool calling, search, fetch, and UI
Full-stack real-time group chat with Rust/Axum backend and Riot.js frontend. Features: - Auth (register/login/JWT), rooms, invites, WebSocket messaging - AI responses via OpenRouter with tool calling (Brave Search + web fetch) - Real-time tool usage indicators (searching/reading page) - Collapsible tool results in message bubbles - AI stats bar (model, tokens, speed, response time) persisted to DB - Room soft-delete, /clear command, dynamic model fetching - Markdown rendering with code highlighting and copy buttons Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
01258fa958
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)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
31
.gitignore
vendored
Normal file
31
.gitignore
vendored
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
# Rust
|
||||||
|
server/target/
|
||||||
|
server/chat.db
|
||||||
|
server/chat.db-journal
|
||||||
|
server/chat.db-wal
|
||||||
|
|
||||||
|
# Node
|
||||||
|
client/node_modules/
|
||||||
|
client/dist/
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
server/.env
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
.server-err.log
|
||||||
|
.client-err.log
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
nul
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
14
client/index.html
Normal file
14
client/index.html
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>GroupChat</title>
|
||||||
|
<link rel="stylesheet" href="/src/styles/global.css" />
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.10.0/styles/github-dark.min.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1843
client/package-lock.json
generated
Normal file
1843
client/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
client/package.json
Normal file
23
client/package.json
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "groupchat-client",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"riot": "^9.4.4",
|
||||||
|
"@riotjs/compiler": "^9.4.4",
|
||||||
|
"@riotjs/route": "^9.0.2",
|
||||||
|
"markdown-it": "^14.1.0",
|
||||||
|
"highlight.js": "^11.10.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"vite": "^5.4.0",
|
||||||
|
"@anthropic-ai/sdk": "^0.26.0",
|
||||||
|
"@nicolo-ribaudo/chokidar-2": "^2.1.8-no-fsevents.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
322
client/src/components/app.riot
Normal file
322
client/src/components/app.riot
Normal file
@ -0,0 +1,322 @@
|
|||||||
|
<app>
|
||||||
|
<div class="app-container">
|
||||||
|
<!-- Auth screens -->
|
||||||
|
<template if={!state.user}>
|
||||||
|
<login-page if={state.authView === 'login'} cb-login={handleLogin} cb-switch={() => update({ authView: 'register' })} />
|
||||||
|
<register-page if={state.authView === 'register'} cb-register={handleLogin} cb-switch={() => update({ authView: 'login' })} />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Main chat layout -->
|
||||||
|
<template if={state.user}>
|
||||||
|
<div class="chat-layout">
|
||||||
|
<chat-sidebar
|
||||||
|
rooms={state.rooms}
|
||||||
|
active-room-id={state.activeRoomId}
|
||||||
|
user={state.user}
|
||||||
|
cb-select-room={selectRoom}
|
||||||
|
cb-create-room={() => update({ showCreateModal: true })}
|
||||||
|
cb-logout={handleLogout}
|
||||||
|
/>
|
||||||
|
<main class="chat-main">
|
||||||
|
<chat-room
|
||||||
|
if={state.activeRoom}
|
||||||
|
room={state.activeRoom}
|
||||||
|
messages={state.messages}
|
||||||
|
user={state.user}
|
||||||
|
ai-typing={state.aiTyping}
|
||||||
|
ai-tool-status={state.aiToolStatus}
|
||||||
|
typing-users={state.typingUsers}
|
||||||
|
cb-send={sendMessage}
|
||||||
|
cb-invite={() => update({ showInviteModal: true })}
|
||||||
|
cb-delete-room={() => update({ showDeleteModal: true })}
|
||||||
|
cb-clear-room={() => update({ showClearModal: true })}
|
||||||
|
/>
|
||||||
|
<div if={!state.activeRoom} class="no-room">
|
||||||
|
<div class="no-room-content">
|
||||||
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||||
|
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
||||||
|
</svg>
|
||||||
|
<h2>Welcome to GroupChat</h2>
|
||||||
|
<p>Select a room or create a new one to start chatting</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<create-room-modal
|
||||||
|
if={state.showCreateModal}
|
||||||
|
cb-create={handleCreateRoom}
|
||||||
|
cb-close={() => update({ showCreateModal: false })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<invite-modal
|
||||||
|
if={state.showInviteModal}
|
||||||
|
room-id={state.activeRoomId}
|
||||||
|
cb-close={() => update({ showInviteModal: false })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<delete-room-modal
|
||||||
|
if={state.showDeleteModal}
|
||||||
|
room={state.activeRoom}
|
||||||
|
cb-delete={handleDeleteRoom}
|
||||||
|
cb-close={() => update({ showDeleteModal: false })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<clear-confirm-modal
|
||||||
|
if={state.showClearModal}
|
||||||
|
room={state.activeRoom}
|
||||||
|
cb-confirm={confirmClearRoom}
|
||||||
|
cb-close={() => update({ showClearModal: false })}
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.app-container {
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-container > :first-child {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-layout {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-main {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Riot.js custom element wrappers must fill their flex parent */
|
||||||
|
.chat-layout > chat-sidebar {
|
||||||
|
display: flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-main > chat-room {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-room {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-room-content {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-room-content svg {
|
||||||
|
margin-bottom: var(--space-md);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-room-content h2 {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: var(--space-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-room-content p {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { api, saveAuth, getUser, clearAuth, isAuthenticated } from '../services/api.js'
|
||||||
|
import { ws } from '../services/websocket.js'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
state: {
|
||||||
|
user: null,
|
||||||
|
authView: 'login',
|
||||||
|
rooms: [],
|
||||||
|
activeRoomId: null,
|
||||||
|
activeRoom: null,
|
||||||
|
messages: [],
|
||||||
|
showCreateModal: false,
|
||||||
|
showInviteModal: false,
|
||||||
|
showDeleteModal: false,
|
||||||
|
showClearModal: false,
|
||||||
|
aiTyping: false,
|
||||||
|
aiToolStatus: null,
|
||||||
|
typingUsers: [],
|
||||||
|
},
|
||||||
|
|
||||||
|
onMounted() {
|
||||||
|
const user = getUser()
|
||||||
|
if (user && isAuthenticated()) {
|
||||||
|
this.update({ user })
|
||||||
|
this.initChat()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
onUnmounted() {
|
||||||
|
ws.disconnect()
|
||||||
|
},
|
||||||
|
|
||||||
|
async initChat() {
|
||||||
|
const token = localStorage.getItem('token')
|
||||||
|
ws.connect(token)
|
||||||
|
|
||||||
|
ws.on('new_message', (msg) => {
|
||||||
|
if (msg.message.room_id === this.state.activeRoomId) {
|
||||||
|
this.update({
|
||||||
|
messages: [...this.state.messages, msg.message],
|
||||||
|
aiTyping: false,
|
||||||
|
aiToolStatus: null,
|
||||||
|
})
|
||||||
|
this.scrollToBottom()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
ws.on('ai_typing', (msg) => {
|
||||||
|
if (msg.room_id === this.state.activeRoomId) {
|
||||||
|
this.update({ aiTyping: true })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
ws.on('ai_tool_usage', (msg) => {
|
||||||
|
if (msg.room_id === this.state.activeRoomId) {
|
||||||
|
this.update({
|
||||||
|
aiToolStatus: { tool: msg.tool_name, status: msg.status },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
ws.on('user_typing', (msg) => {
|
||||||
|
if (msg.room_id === this.state.activeRoomId && msg.user_id !== this.state.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 })
|
||||||
|
this.update({ typingUsers: users })
|
||||||
|
setTimeout(() => {
|
||||||
|
this.update({
|
||||||
|
typingUsers: this.state.typingUsers.filter(u => u.user_id !== msg.user_id)
|
||||||
|
})
|
||||||
|
}, 3000)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
ws.on('room_deleted', (msg) => {
|
||||||
|
const rooms = this.state.rooms.filter(r => r.id !== msg.room_id)
|
||||||
|
const resetActive = this.state.activeRoomId === msg.room_id
|
||||||
|
this.update({
|
||||||
|
rooms,
|
||||||
|
activeRoomId: resetActive ? null : this.state.activeRoomId,
|
||||||
|
activeRoom: resetActive ? null : this.state.activeRoom,
|
||||||
|
messages: resetActive ? [] : this.state.messages,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
ws.on('room_cleared', (msg) => {
|
||||||
|
if (msg.room_id === this.state.activeRoomId) {
|
||||||
|
this.update({ messages: [] })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rooms = await api.listRooms()
|
||||||
|
this.update({ rooms })
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load rooms:', e)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
handleLogin(data) {
|
||||||
|
saveAuth(data.token, data.user)
|
||||||
|
this.update({ user: data.user })
|
||||||
|
this.initChat()
|
||||||
|
},
|
||||||
|
|
||||||
|
handleLogout() {
|
||||||
|
ws.disconnect()
|
||||||
|
clearAuth()
|
||||||
|
this.update({
|
||||||
|
user: null,
|
||||||
|
rooms: [],
|
||||||
|
activeRoomId: null,
|
||||||
|
activeRoom: null,
|
||||||
|
messages: [],
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
async selectRoom(roomId) {
|
||||||
|
try {
|
||||||
|
const [room, messages] = await Promise.all([
|
||||||
|
api.getRoom(roomId),
|
||||||
|
api.getMessages(roomId),
|
||||||
|
])
|
||||||
|
this.update({
|
||||||
|
activeRoomId: roomId,
|
||||||
|
activeRoom: room,
|
||||||
|
messages,
|
||||||
|
aiTyping: false,
|
||||||
|
aiToolStatus: null,
|
||||||
|
typingUsers: [],
|
||||||
|
})
|
||||||
|
ws.joinRoom(roomId)
|
||||||
|
this.scrollToBottom()
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load room:', e)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async handleCreateRoom(data) {
|
||||||
|
try {
|
||||||
|
const room = await api.createRoom(data)
|
||||||
|
const rooms = [room, ...this.state.rooms]
|
||||||
|
this.update({ rooms, showCreateModal: false })
|
||||||
|
this.selectRoom(room.id)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to create room:', e)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
sendMessage({ content, mentions }) {
|
||||||
|
ws.sendMessage(this.state.activeRoomId, content, mentions)
|
||||||
|
},
|
||||||
|
|
||||||
|
handleDeleteRoom(roomId) {
|
||||||
|
const rooms = this.state.rooms.filter(r => r.id !== roomId)
|
||||||
|
const resetActive = this.state.activeRoomId === roomId
|
||||||
|
this.update({
|
||||||
|
rooms,
|
||||||
|
showDeleteModal: false,
|
||||||
|
activeRoomId: resetActive ? null : this.state.activeRoomId,
|
||||||
|
activeRoom: resetActive ? null : this.state.activeRoom,
|
||||||
|
messages: resetActive ? [] : this.state.messages,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
confirmClearRoom() {
|
||||||
|
this.update({ messages: [], showClearModal: false })
|
||||||
|
},
|
||||||
|
|
||||||
|
scrollToBottom() {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const container = document.querySelector('.messages-list')
|
||||||
|
if (container) {
|
||||||
|
container.scrollTop = container.scrollHeight
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</app>
|
||||||
353
client/src/components/chat-room.riot
Normal file
353
client/src/components/chat-room.riot
Normal file
@ -0,0 +1,353 @@
|
|||||||
|
<chat-room>
|
||||||
|
<div class="chat-room">
|
||||||
|
<!-- Header -->
|
||||||
|
<header class="room-header">
|
||||||
|
<div class="room-header-info">
|
||||||
|
<h3>{props.room?.name}</h3>
|
||||||
|
<span class="room-meta">
|
||||||
|
{props.room?.model_id?.split('/').pop()} · {props.room?.members?.length || 0} members
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="room-actions">
|
||||||
|
<button class="btn btn-ghost btn-icon" onclick={props.cbInvite} title="Invite">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
|
||||||
|
<circle cx="8.5" cy="7" r="4"/>
|
||||||
|
<line x1="20" y1="8" x2="20" y2="14"/><line x1="23" y1="11" x2="17" y2="11"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
if={props.user?.id === props.room?.created_by}
|
||||||
|
class="btn btn-ghost btn-icon btn-danger-icon"
|
||||||
|
onclick={props.cbDeleteRoom}
|
||||||
|
title="Delete Room"
|
||||||
|
>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<polyline points="3 6 5 6 21 6"/>
|
||||||
|
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Messages -->
|
||||||
|
<div class="messages-list" ref="messagesList">
|
||||||
|
<div class="messages-spacer"></div>
|
||||||
|
<div each={msg in props.messages} key={msg.id}>
|
||||||
|
<message-bubble
|
||||||
|
message={msg}
|
||||||
|
is-own={msg.sender_id === props.user?.id}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div if={props.aiTyping} class="typing-indicator ai-typing">
|
||||||
|
<div class="typing-avatar ai-avatar">AI</div>
|
||||||
|
<template if={props.aiToolStatus}>
|
||||||
|
<span class="tool-status-text">
|
||||||
|
{props.aiToolStatus.tool === 'brave_search' ? '🔍 Searching...' : props.aiToolStatus.tool === 'web_fetch' ? '🌐 Reading page...' : '⚙️ Using tool...'}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<template if={!props.aiToolStatus}>
|
||||||
|
<div class="typing-dots">
|
||||||
|
<span></span><span></span><span></span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div if={state.typingDisplay} class="typing-indicator">
|
||||||
|
<span class="typing-text">{state.typingDisplay}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Input -->
|
||||||
|
<div class="message-input-area">
|
||||||
|
<div class="input-wrapper">
|
||||||
|
<textarea
|
||||||
|
ref="input"
|
||||||
|
class="message-input"
|
||||||
|
placeholder="Type a message... (use @ai to mention the AI)"
|
||||||
|
rows="1"
|
||||||
|
oninput={handleInput}
|
||||||
|
onkeydown={handleKeydown}
|
||||||
|
></textarea>
|
||||||
|
<button
|
||||||
|
class="btn btn-primary send-btn"
|
||||||
|
onclick={handleSend}
|
||||||
|
disabled={!state.inputValue?.trim()}
|
||||||
|
>
|
||||||
|
<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"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.chat-room {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 var(--space-lg);
|
||||||
|
height: var(--header-height);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-header-info h3 {
|
||||||
|
font-size: var(--text-base);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-meta {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger-icon:hover {
|
||||||
|
color: var(--error, #e53e3e) !important;
|
||||||
|
background: rgba(229, 62, 62, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: var(--space-md) var(--space-lg);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages-spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.typing-indicator {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
padding: var(--space-xs) var(--space-sm);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-typing {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.typing-avatar {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 600;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-avatar {
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.typing-dots {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.typing-dots span {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
background: var(--text-muted);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
animation: typing-bounce 1.4s infinite ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.typing-dots span:nth-child(2) {
|
||||||
|
animation-delay: 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.typing-dots span:nth-child(3) {
|
||||||
|
animation-delay: 0.4s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes typing-bounce {
|
||||||
|
0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
|
||||||
|
40% { transform: scale(1); opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.typing-text {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-status-text {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 500;
|
||||||
|
animation: tool-pulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes tool-pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.5; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-input-area {
|
||||||
|
padding: var(--space-sm) var(--space-lg) var(--space-md);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-wrapper {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: var(--space-sm);
|
||||||
|
transition: border-color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-wrapper:focus-within {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-input {
|
||||||
|
flex: 1;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-primary);
|
||||||
|
padding: var(--space-xs) var(--space-sm);
|
||||||
|
resize: none;
|
||||||
|
max-height: 120px;
|
||||||
|
line-height: 1.5;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-input::placeholder {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.send-btn {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.send-btn:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { ws } from '../services/websocket.js'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
state: {
|
||||||
|
inputValue: '',
|
||||||
|
typingDisplay: '',
|
||||||
|
},
|
||||||
|
|
||||||
|
onUpdated() {
|
||||||
|
// Auto-resize textarea
|
||||||
|
const textarea = this.$('textarea')
|
||||||
|
if (textarea) {
|
||||||
|
textarea.style.height = 'auto'
|
||||||
|
textarea.style.height = Math.min(textarea.scrollHeight, 120) + 'px'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build typing display text
|
||||||
|
const users = this.props.typingUsers || []
|
||||||
|
let typingDisplay = ''
|
||||||
|
if (users.length === 1) {
|
||||||
|
typingDisplay = `${users[0].display_name} is typing...`
|
||||||
|
} else if (users.length > 1) {
|
||||||
|
typingDisplay = `${users.length} people are typing...`
|
||||||
|
}
|
||||||
|
if (typingDisplay !== this.state.typingDisplay) {
|
||||||
|
this.state.typingDisplay = typingDisplay
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
handleInput(e) {
|
||||||
|
this.update({ inputValue: e.target.value })
|
||||||
|
if (this.props.room) {
|
||||||
|
ws.sendTyping(this.props.room.id)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
handleKeydown(e) {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault()
|
||||||
|
this.handleSend()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
handleSend() {
|
||||||
|
const content = this.state.inputValue?.trim()
|
||||||
|
if (!content) return
|
||||||
|
|
||||||
|
// Intercept /clear command — room creator only
|
||||||
|
if (content === '/clear') {
|
||||||
|
if (this.props.user?.id === this.props.room?.created_by) {
|
||||||
|
this.update({ inputValue: '' })
|
||||||
|
const textarea = this.$('textarea')
|
||||||
|
if (textarea) {
|
||||||
|
textarea.value = ''
|
||||||
|
textarea.style.height = 'auto'
|
||||||
|
}
|
||||||
|
this.props.cbClearRoom()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract mentions (simple @ai detection)
|
||||||
|
const mentions = []
|
||||||
|
if (content.toLowerCase().includes('@ai')) {
|
||||||
|
mentions.push('ai-assistant')
|
||||||
|
}
|
||||||
|
|
||||||
|
this.props.cbSend({ content, mentions })
|
||||||
|
this.update({ inputValue: '' })
|
||||||
|
|
||||||
|
const textarea = this.$('textarea')
|
||||||
|
if (textarea) {
|
||||||
|
textarea.value = ''
|
||||||
|
textarea.style.height = 'auto'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</chat-room>
|
||||||
200
client/src/components/chat-sidebar.riot
Normal file
200
client/src/components/chat-sidebar.riot
Normal file
@ -0,0 +1,200 @@
|
|||||||
|
<chat-sidebar>
|
||||||
|
<aside class="sidebar">
|
||||||
|
<div class="sidebar-header">
|
||||||
|
<h2>GroupChat</h2>
|
||||||
|
<button class="btn btn-ghost btn-icon" onclick={props.cbCreateRoom} title="New Room">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="room-list">
|
||||||
|
<div
|
||||||
|
each={room in props.rooms}
|
||||||
|
key={room.id}
|
||||||
|
class={'room-item ' + (room.id === props.activeRoomId ? 'active' : '')}
|
||||||
|
onclick={() => props.cbSelectRoom(room.id)}
|
||||||
|
>
|
||||||
|
<div class="room-avatar">
|
||||||
|
{room.name.charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div class="room-info">
|
||||||
|
<span class="room-name">{room.name}</span>
|
||||||
|
<span class="room-model">{room.model_id.split('/').pop()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div if={!props.rooms || props.rooms.length === 0} class="empty-rooms">
|
||||||
|
<p>No rooms yet</p>
|
||||||
|
<button class="btn btn-primary btn-sm" onclick={props.cbCreateRoom}>Create one</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sidebar-footer">
|
||||||
|
<div class="user-info">
|
||||||
|
<div class="user-avatar">
|
||||||
|
{props.user?.display_name?.charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<span class="user-name">{props.user?.display_name}</span>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-ghost btn-icon" onclick={props.cbLogout} title="Logout">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
|
||||||
|
<polyline points="16 17 21 12 16 7"/>
|
||||||
|
<line x1="21" y1="12" x2="9" y2="12"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.sidebar {
|
||||||
|
width: var(--sidebar-width);
|
||||||
|
min-width: var(--sidebar-width);
|
||||||
|
height: 100%;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: var(--space-md) var(--space-md);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
height: var(--header-height);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header h2 {
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
padding: var(--space-sm) var(--space-md);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-item:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-item.active {
|
||||||
|
background: var(--accent-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-avatar {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--accent);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-info {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-name {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-model {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-rooms {
|
||||||
|
text-align: center;
|
||||||
|
padding: var(--space-xl);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-rooms p {
|
||||||
|
margin-bottom: var(--space-md);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-sm {
|
||||||
|
padding: var(--space-xs) var(--space-md);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: var(--space-sm) var(--space-md);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-avatar {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-name {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {}
|
||||||
|
</script>
|
||||||
|
</chat-sidebar>
|
||||||
176
client/src/components/clear-confirm-modal.riot
Normal file
176
client/src/components/clear-confirm-modal.riot
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
<clear-confirm-modal>
|
||||||
|
<div class="modal-overlay" onclick={handleOverlayClick}>
|
||||||
|
<div class="modal" onclick={e => e.stopPropagation()}>
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>Clear Chat</h3>
|
||||||
|
<button class="btn btn-ghost btn-icon" onclick={props.cbClose}>
|
||||||
|
<svg width="18" height="18" 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="clear-warning">
|
||||||
|
<div class="warning-icon">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<polyline points="3 6 5 6 21 6"/>
|
||||||
|
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p>This will permanently delete <strong>all messages</strong> in <strong>{props.room?.name}</strong>.</p>
|
||||||
|
<p class="warning-sub">This cannot be undone. The room will remain, but all chat history will be lost.</p>
|
||||||
|
</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
|
||||||
|
class="btn btn-danger"
|
||||||
|
onclick={handleClear}
|
||||||
|
disabled={state.loading}
|
||||||
|
>
|
||||||
|
{state.loading ? 'Clearing...' : 'Clear All Messages'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.modal-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;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: var(--space-lg);
|
||||||
|
animation: slideUp 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideUp {
|
||||||
|
from { transform: translateY(16px); opacity: 0; }
|
||||||
|
to { transform: translateY(0); opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header h3 {
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--error, #e53e3e);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-warning {
|
||||||
|
margin-bottom: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-icon {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: var(--space-md);
|
||||||
|
color: var(--error, #e53e3e);
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-warning p {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.6;
|
||||||
|
margin-bottom: var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-sub {
|
||||||
|
color: var(--text-muted) !important;
|
||||||
|
font-size: var(--text-xs) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-text {
|
||||||
|
color: var(--error, #e53e3e);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
margin-bottom: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
margin-top: var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: var(--error, #e53e3e);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: var(--space-sm) var(--space-md);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover:not(:disabled) {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { api } from '../services/api.js'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
state: {
|
||||||
|
error: null,
|
||||||
|
loading: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
handleOverlayClick() {
|
||||||
|
this.props.cbClose()
|
||||||
|
},
|
||||||
|
|
||||||
|
async handleClear() {
|
||||||
|
this.update({ loading: true, error: null })
|
||||||
|
try {
|
||||||
|
await api.clearRoom(this.props.room.id)
|
||||||
|
this.props.cbConfirm()
|
||||||
|
} catch (err) {
|
||||||
|
this.update({ error: err.message, loading: false })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</clear-confirm-modal>
|
||||||
437
client/src/components/create-room-modal.riot
Normal file
437
client/src/components/create-room-modal.riot
Normal file
@ -0,0 +1,437 @@
|
|||||||
|
<create-room-modal>
|
||||||
|
<div class="modal-overlay" onclick={handleOverlayClick}>
|
||||||
|
<div class="modal" onclick={e => e.stopPropagation()}>
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>Create New Room</h3>
|
||||||
|
<button class="btn btn-ghost btn-icon" onclick={props.cbClose}>
|
||||||
|
<svg width="18" height="18" 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>
|
||||||
|
|
||||||
|
<form onsubmit={handleSubmit}>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="room-name">Room Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="room-name"
|
||||||
|
placeholder="e.g., Project Discussion"
|
||||||
|
value={state.name}
|
||||||
|
oninput={e => update({ name: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group model-picker-group">
|
||||||
|
<label>AI Model</label>
|
||||||
|
<div class="model-picker-selected" onclick={toggleModelPicker}>
|
||||||
|
<span class="model-selected-name">
|
||||||
|
{getSelectedModelName()}
|
||||||
|
</span>
|
||||||
|
<svg class={'model-chevron ' + (state.showPicker ? 'open' : '')} width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<polyline points="6 9 12 15 18 9"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div if={state.showPicker} class="model-picker-dropdown">
|
||||||
|
<div class="model-search-wrap">
|
||||||
|
<svg class="model-search-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="model-search"
|
||||||
|
placeholder="Search models..."
|
||||||
|
value={state.modelSearch}
|
||||||
|
oninput={handleModelSearch}
|
||||||
|
ref="modelSearchInput"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="model-list">
|
||||||
|
<div if={state.loadingModels} class="model-list-loading">
|
||||||
|
Loading models...
|
||||||
|
</div>
|
||||||
|
<div if={!state.loadingModels && filteredModels().length === 0} class="model-list-empty">
|
||||||
|
No models found
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
each={model in filteredModels()}
|
||||||
|
key={model.id}
|
||||||
|
class={'model-option ' + (model.id === state.model_id ? 'selected' : '')}
|
||||||
|
onclick={() => selectModel(model)}
|
||||||
|
>
|
||||||
|
<div class="model-option-name">{model.name}</div>
|
||||||
|
<div class="model-option-meta">
|
||||||
|
<span class="model-option-id">{model.id}</span>
|
||||||
|
<span if={model.context_length} class="model-option-ctx">{formatCtx(model.context_length)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="help-text">Choose the AI model that powers this room</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={state.ai_always_respond}
|
||||||
|
onchange={e => update({ ai_always_respond: e.target.checked })}
|
||||||
|
/>
|
||||||
|
<span>AI responds to every message</span>
|
||||||
|
</label>
|
||||||
|
<span class="help-text">When off, mention @ai to trigger a response</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="system-prompt">System Prompt</label>
|
||||||
|
<textarea
|
||||||
|
id="system-prompt"
|
||||||
|
rows="3"
|
||||||
|
placeholder="Instructions for the AI..."
|
||||||
|
value={state.system_prompt}
|
||||||
|
oninput={e => update({ system_prompt: e.target.value })}
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="button" class="btn btn-ghost" onclick={props.cbClose}>Cancel</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Create Room</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.modal-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;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 480px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: var(--space-lg);
|
||||||
|
animation: slideUp 0.2s ease;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideUp {
|
||||||
|
from { transform: translateY(16px); opacity: 0; }
|
||||||
|
to { transform: translateY(0); opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header h3 {
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-text {
|
||||||
|
display: block;
|
||||||
|
margin-top: var(--space-xs);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-label {
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-label input[type="checkbox"] {
|
||||||
|
width: auto;
|
||||||
|
accent-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-label span {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
min-height: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
margin-top: var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Model picker */
|
||||||
|
.model-picker-group {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-picker-selected {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: var(--space-sm) var(--space-md);
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-picker-selected:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-selected-name {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-primary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-chevron {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
transition: transform 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-chevron.open {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-picker-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
margin-top: 4px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
z-index: 50;
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-search-wrap {
|
||||||
|
position: relative;
|
||||||
|
padding: var(--space-sm);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-search-icon {
|
||||||
|
position: absolute;
|
||||||
|
left: calc(var(--space-sm) + 10px);
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
color: var(--text-muted);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-search {
|
||||||
|
width: 100%;
|
||||||
|
padding-left: 32px !important;
|
||||||
|
font-size: var(--text-sm) !important;
|
||||||
|
background: var(--bg-primary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-list {
|
||||||
|
max-height: 260px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-list-loading,
|
||||||
|
.model-list-empty {
|
||||||
|
padding: var(--space-md);
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-option {
|
||||||
|
padding: var(--space-sm) var(--space-md);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.1s;
|
||||||
|
border-bottom: 1px solid var(--border-subtle, rgba(255,255,255,0.04));
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-option:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-option.selected {
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
border-left: 2px solid var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-option-name {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-option-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-option-id {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-option-ctx {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { api } from '../services/api.js'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
state: {
|
||||||
|
name: '',
|
||||||
|
model_id: 'anthropic/claude-sonnet-4',
|
||||||
|
ai_always_respond: false,
|
||||||
|
system_prompt: 'You are a helpful AI assistant participating in a group chat. Be conversational, helpful, and concise.',
|
||||||
|
models: [],
|
||||||
|
loadingModels: true,
|
||||||
|
showPicker: false,
|
||||||
|
modelSearch: '',
|
||||||
|
},
|
||||||
|
|
||||||
|
onMounted() {
|
||||||
|
this.fetchModels()
|
||||||
|
// Close picker on outside click
|
||||||
|
this._onDocClick = (e) => {
|
||||||
|
if (this.state.showPicker && !this.$('.model-picker-group')?.contains(e.target)) {
|
||||||
|
this.update({ showPicker: false })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('click', this._onDocClick)
|
||||||
|
},
|
||||||
|
|
||||||
|
onUnmounted() {
|
||||||
|
document.removeEventListener('click', this._onDocClick)
|
||||||
|
},
|
||||||
|
|
||||||
|
async fetchModels() {
|
||||||
|
try {
|
||||||
|
const models = await api.listModels()
|
||||||
|
this.update({ models, loadingModels: false })
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch models:', err)
|
||||||
|
// Fall back to a small default list
|
||||||
|
this.update({
|
||||||
|
loadingModels: false,
|
||||||
|
models: [
|
||||||
|
{ id: 'anthropic/claude-sonnet-4', name: 'Claude Sonnet 4', context_length: 200000 },
|
||||||
|
{ id: 'openai/gpt-4o', name: 'GPT-4o', context_length: 128000 },
|
||||||
|
{ id: 'google/gemini-2.0-flash-001', name: 'Gemini 2.0 Flash', context_length: 1048576 },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleModelPicker(e) {
|
||||||
|
e.stopPropagation()
|
||||||
|
const opening = !this.state.showPicker
|
||||||
|
this.update({ showPicker: opening, modelSearch: '' })
|
||||||
|
if (opening) {
|
||||||
|
setTimeout(() => this.$('.model-search')?.focus(), 50)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
handleModelSearch(e) {
|
||||||
|
this.update({ modelSearch: e.target.value })
|
||||||
|
},
|
||||||
|
|
||||||
|
selectModel(model) {
|
||||||
|
this.update({ model_id: model.id, showPicker: false, modelSearch: '' })
|
||||||
|
},
|
||||||
|
|
||||||
|
filteredModels() {
|
||||||
|
const q = this.state.modelSearch.toLowerCase().trim()
|
||||||
|
if (!q) return this.state.models
|
||||||
|
return this.state.models.filter(
|
||||||
|
(m) => m.name.toLowerCase().includes(q) || m.id.toLowerCase().includes(q)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
|
||||||
|
getSelectedModelName() {
|
||||||
|
const m = this.state.models.find((m) => m.id === this.state.model_id)
|
||||||
|
return m ? m.name : this.state.model_id
|
||||||
|
},
|
||||||
|
|
||||||
|
formatCtx(n) {
|
||||||
|
if (!n) return ''
|
||||||
|
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M ctx'
|
||||||
|
if (n >= 1000) return Math.round(n / 1000) + 'K ctx'
|
||||||
|
return n + ' ctx'
|
||||||
|
},
|
||||||
|
|
||||||
|
handleOverlayClick() {
|
||||||
|
this.props.cbClose()
|
||||||
|
},
|
||||||
|
|
||||||
|
handleSubmit(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
this.props.cbCreate({
|
||||||
|
name: this.state.name,
|
||||||
|
model_id: this.state.model_id,
|
||||||
|
ai_always_respond: this.state.ai_always_respond,
|
||||||
|
system_prompt: this.state.system_prompt,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</create-room-modal>
|
||||||
208
client/src/components/delete-room-modal.riot
Normal file
208
client/src/components/delete-room-modal.riot
Normal file
@ -0,0 +1,208 @@
|
|||||||
|
<delete-room-modal>
|
||||||
|
<div class="modal-overlay" onclick={handleOverlayClick}>
|
||||||
|
<div class="modal" onclick={e => e.stopPropagation()}>
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>Delete Room</h3>
|
||||||
|
<button class="btn btn-ghost btn-icon" onclick={props.cbClose}>
|
||||||
|
<svg width="18" height="18" 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="delete-warning">
|
||||||
|
<div class="warning-icon">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
|
||||||
|
<line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p>This will permanently remove <strong>{props.room?.name}</strong> from everyone's view. All messages will be hidden and members will lose access.</p>
|
||||||
|
<p class="warning-sub">This action cannot be easily undone. Type the room name below to confirm.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onsubmit={handleSubmit}>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Type <strong>{props.room?.name}</strong> to confirm</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder={props.room?.name}
|
||||||
|
value={state.confirmName}
|
||||||
|
oninput={e => update({ confirmName: e.target.value })}
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
</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-danger"
|
||||||
|
disabled={state.confirmName !== props.room?.name || state.loading}
|
||||||
|
>
|
||||||
|
{state.loading ? 'Deleting...' : 'Delete Room'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.modal-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;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 440px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: var(--space-lg);
|
||||||
|
animation: slideUp 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideUp {
|
||||||
|
from { transform: translateY(16px); opacity: 0; }
|
||||||
|
to { transform: translateY(0); opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header h3 {
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--error, #e53e3e);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-warning {
|
||||||
|
margin-bottom: var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-icon {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: var(--space-md);
|
||||||
|
color: var(--error, #e53e3e);
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-warning p {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.6;
|
||||||
|
margin-bottom: var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-warning strong {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-sub {
|
||||||
|
color: var(--text-muted) !important;
|
||||||
|
font-size: var(--text-xs) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-text {
|
||||||
|
color: var(--error, #e53e3e);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
margin-bottom: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
margin-top: var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: var(--error, #e53e3e);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: var(--space-sm) var(--space-md);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover:not(:disabled) {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { api } from '../services/api.js'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
state: {
|
||||||
|
confirmName: '',
|
||||||
|
error: null,
|
||||||
|
loading: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
handleOverlayClick() {
|
||||||
|
this.props.cbClose()
|
||||||
|
},
|
||||||
|
|
||||||
|
async handleSubmit(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (this.state.confirmName !== this.props.room?.name) return
|
||||||
|
|
||||||
|
this.update({ loading: true, error: null })
|
||||||
|
try {
|
||||||
|
await api.deleteRoom(this.props.room.id)
|
||||||
|
this.props.cbDelete(this.props.room.id)
|
||||||
|
} catch (err) {
|
||||||
|
this.update({ error: err.message, loading: false })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</delete-room-modal>
|
||||||
193
client/src/components/invite-modal.riot
Normal file
193
client/src/components/invite-modal.riot
Normal file
@ -0,0 +1,193 @@
|
|||||||
|
<invite-modal>
|
||||||
|
<div class="modal-overlay" onclick={handleOverlayClick}>
|
||||||
|
<div class="modal" onclick={e => e.stopPropagation()}>
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>Invite to Room</h3>
|
||||||
|
<button class="btn btn-ghost btn-icon" onclick={props.cbClose}>
|
||||||
|
<svg width="18" height="18" 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>
|
||||||
|
|
||||||
|
<form if={!state.inviteUrl} onsubmit={handleSubmit}>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="invite-email">Email address</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
id="invite-email"
|
||||||
|
placeholder="friend@example.com"
|
||||||
|
value={state.email}
|
||||||
|
oninput={e => update({ email: 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.loading}>
|
||||||
|
{state.loading ? 'Sending...' : 'Send Invite'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div if={state.inviteUrl} class="invite-success">
|
||||||
|
<p>Invite link generated!</p>
|
||||||
|
<div class="invite-link-box">
|
||||||
|
<code>{state.inviteUrl}</code>
|
||||||
|
<button class="btn btn-ghost btn-sm" onclick={copyLink}>Copy</button>
|
||||||
|
</div>
|
||||||
|
<p class="help-text">Share this link with your friend</p>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="btn btn-primary" onclick={props.cbClose}>Done</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 440px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header h3 {
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-text {
|
||||||
|
color: var(--error);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
margin-bottom: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
margin-top: var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-success {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-success > p:first-child {
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-link-box {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: var(--space-sm) var(--space-md);
|
||||||
|
margin-bottom: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-link-box code {
|
||||||
|
flex: 1;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--accent);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-sm {
|
||||||
|
padding: var(--space-xs) var(--space-sm);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-text {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { api } from '../services/api.js'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
state: {
|
||||||
|
email: '',
|
||||||
|
error: null,
|
||||||
|
loading: false,
|
||||||
|
inviteUrl: null,
|
||||||
|
},
|
||||||
|
|
||||||
|
handleOverlayClick() {
|
||||||
|
this.props.cbClose()
|
||||||
|
},
|
||||||
|
|
||||||
|
async handleSubmit(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
this.update({ loading: true, error: null })
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await api.createInvite({
|
||||||
|
room_id: this.props.roomId,
|
||||||
|
email: this.state.email,
|
||||||
|
})
|
||||||
|
this.update({
|
||||||
|
inviteUrl: `${window.location.origin}${result.invite_url}`,
|
||||||
|
loading: false,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
this.update({ error: err.message, loading: false })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
copyLink() {
|
||||||
|
navigator.clipboard.writeText(this.state.inviteUrl)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</invite-modal>
|
||||||
142
client/src/components/login-page.riot
Normal file
142
client/src/components/login-page.riot
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
<login-page>
|
||||||
|
<div class="auth-page">
|
||||||
|
<div class="auth-card">
|
||||||
|
<div class="auth-header">
|
||||||
|
<h1>GroupChat</h1>
|
||||||
|
<p>Sign in to continue</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onsubmit={handleSubmit}>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="email">Email</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
id="email"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
value={state.email}
|
||||||
|
oninput={e => update({ email: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password">Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="password"
|
||||||
|
placeholder="Your password"
|
||||||
|
value={state.password}
|
||||||
|
oninput={e => update({ password: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p if={state.error} class="error-text">{state.error}</p>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary btn-full" disabled={state.loading}>
|
||||||
|
{state.loading ? 'Signing in...' : 'Sign In'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p class="auth-footer">
|
||||||
|
Don't have an account?
|
||||||
|
<a href="#" onclick={e => { e.preventDefault(); props.cbSwitch() }}>Register</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.auth-page {
|
||||||
|
width: 100%;
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
padding: var(--space-2xl);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: var(--space-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-header h1 {
|
||||||
|
font-size: var(--text-2xl);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent);
|
||||||
|
margin-bottom: var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-header p {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-full {
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--space-sm) var(--space-md);
|
||||||
|
margin-top: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-text {
|
||||||
|
color: var(--error);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
margin-bottom: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-footer {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: var(--space-lg);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { api } from '../services/api.js'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
state: {
|
||||||
|
email: '',
|
||||||
|
password: '',
|
||||||
|
error: null,
|
||||||
|
loading: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
async handleSubmit(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
this.update({ loading: true, error: null })
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await api.login({
|
||||||
|
email: this.state.email,
|
||||||
|
password: this.state.password,
|
||||||
|
})
|
||||||
|
this.props.cbLogin(data)
|
||||||
|
} catch (err) {
|
||||||
|
this.update({ error: err.message, loading: false })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</login-page>
|
||||||
383
client/src/components/message-bubble.riot
Normal file
383
client/src/components/message-bubble.riot
Normal file
@ -0,0 +1,383 @@
|
|||||||
|
<message-bubble>
|
||||||
|
<div class={'message ' + (props.message?.is_ai ? 'ai-message' : '') + (props.isOwn ? ' own-message' : '')}>
|
||||||
|
<div if={!props.isOwn} class="message-avatar-col">
|
||||||
|
<div class={'message-avatar ' + (props.message?.is_ai ? 'ai-avatar' : '')}>
|
||||||
|
{props.message?.is_ai ? 'AI' : props.message?.sender_name?.charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="message-body">
|
||||||
|
<div if={!props.isOwn} class="message-header">
|
||||||
|
<span class="sender-name">{props.message?.sender_name}</span>
|
||||||
|
<span class="message-time">{formatTime(props.message?.created_at)}</span>
|
||||||
|
</div>
|
||||||
|
<div if={props.isOwn} class="message-header own">
|
||||||
|
<span class="message-time">{formatTime(props.message?.created_at)}</span>
|
||||||
|
</div>
|
||||||
|
<div if={hasToolResults()} class="tool-results-section">
|
||||||
|
<div each={tr in getToolResults()} class="tool-result-item">
|
||||||
|
<button class="tool-result-toggle" onclick={toggleToolResult}>
|
||||||
|
<span class="tool-result-icon">{tr.tool === 'brave_search' ? '🔍' : tr.tool === 'web_fetch' ? '🌐' : '⚙️'}</span>
|
||||||
|
<span class="tool-result-label">{tr.tool === 'brave_search' ? 'Search' : tr.tool === 'web_fetch' ? 'Fetched' : tr.tool}: {tr.input}</span>
|
||||||
|
<span class="tool-result-arrow">▼</span>
|
||||||
|
</button>
|
||||||
|
<div class="tool-result-body collapsed">
|
||||||
|
<pre class="tool-result-content">{tr.result}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="message-content markdown-content"></div>
|
||||||
|
<div if={props.message?.is_ai && props.message?.ai_meta} class="ai-stats-bar">
|
||||||
|
<button class="ai-stat-btn" onclick={copyFullMessage} title="Copy response">
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<span class="ai-stat-item model">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2a4 4 0 0 0-4 4v2H6a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V10a2 2 0 0 0-2-2h-2V6a4 4 0 0 0-4-4z"/></svg>
|
||||||
|
{formatModel(props.message.ai_meta.model)}
|
||||||
|
</span>
|
||||||
|
<span class="ai-stat-item" title="Generation speed">
|
||||||
|
⚡ {calcSpeed(props.message.ai_meta)} tok/sec
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.message {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
max-width: 80%;
|
||||||
|
animation: fadeIn 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.own-message {
|
||||||
|
margin-left: auto;
|
||||||
|
flex-direction: row-reverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; transform: translateY(4px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-avatar-col {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-avatar {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-avatar {
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-body {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-header.own {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sender-name {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-message .sender-name {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-time {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-content {
|
||||||
|
padding: var(--space-sm) var(--space-md);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
line-height: 1.6;
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message:not(.own-message):not(.ai-message) .message-content {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-top-left-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.own-message .message-content {
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
border-top-right-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.own-message .message-content :global(code) {
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.own-message .message-content :global(a) {
|
||||||
|
color: white;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-message .message-content {
|
||||||
|
background: var(--ai-bg);
|
||||||
|
border: 1px solid var(--ai-border);
|
||||||
|
border-top-left-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-results-section {
|
||||||
|
margin-bottom: 6px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-result-item {
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-result-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 5px 10px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background var(--transition-fast);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-result-toggle:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-result-toggle.open {
|
||||||
|
border-bottom-left-radius: 0;
|
||||||
|
border-bottom-right-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-result-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-result-label {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-result-arrow {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 10px;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-result-toggle.open .tool-result-arrow {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-result-body {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-top: none;
|
||||||
|
border-bottom-left-radius: var(--radius-md);
|
||||||
|
border-bottom-right-radius: var(--radius-md);
|
||||||
|
background: var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-result-body.collapsed {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-result-content {
|
||||||
|
margin: 0;
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: var(--font-mono, 'SF Mono', 'Fira Code', monospace);
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-stats-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
margin-top: 4px;
|
||||||
|
padding: 4px var(--space-sm);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-stat-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 3px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-stat-btn:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-stat-btn.copied {
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-stat-item {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-stat-item.model {
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { renderMarkdown } from '../services/markdown.js'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
onMounted() {
|
||||||
|
this.renderContent()
|
||||||
|
},
|
||||||
|
|
||||||
|
onUpdated() {
|
||||||
|
this.renderContent()
|
||||||
|
},
|
||||||
|
|
||||||
|
renderContent() {
|
||||||
|
const el = this.$('.message-content')
|
||||||
|
if (el && this.props.message?.content) {
|
||||||
|
el.innerHTML = renderMarkdown(this.props.message.content)
|
||||||
|
// Inject copy buttons into code blocks
|
||||||
|
el.querySelectorAll('pre').forEach((pre) => {
|
||||||
|
if (pre.querySelector('.code-copy-btn')) return
|
||||||
|
const btn = document.createElement('button')
|
||||||
|
btn.className = 'code-copy-btn'
|
||||||
|
btn.textContent = 'Copy'
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const code = pre.querySelector('code')
|
||||||
|
const text = code ? code.textContent : pre.textContent
|
||||||
|
navigator.clipboard.writeText(text).then(() => {
|
||||||
|
btn.textContent = 'Copied!'
|
||||||
|
btn.classList.add('copied')
|
||||||
|
setTimeout(() => {
|
||||||
|
btn.textContent = 'Copy'
|
||||||
|
btn.classList.remove('copied')
|
||||||
|
}, 2000)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
pre.appendChild(btn)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
formatTime(dateStr) {
|
||||||
|
if (!dateStr) return ''
|
||||||
|
const date = new Date(dateStr)
|
||||||
|
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||||
|
},
|
||||||
|
|
||||||
|
formatModel(model) {
|
||||||
|
if (!model) return 'unknown'
|
||||||
|
// "openai/gpt-4o" → "gpt-4o", "anthropic/claude-3.5-sonnet" → "claude-3.5-sonnet"
|
||||||
|
const parts = model.split('/')
|
||||||
|
return parts.length > 1 ? parts[parts.length - 1] : model
|
||||||
|
},
|
||||||
|
|
||||||
|
calcSpeed(meta) {
|
||||||
|
if (!meta || !meta.completion_tokens || !meta.response_ms) return '–'
|
||||||
|
const seconds = meta.response_ms / 1000
|
||||||
|
if (seconds === 0) return '–'
|
||||||
|
return (meta.completion_tokens / seconds).toFixed(1)
|
||||||
|
},
|
||||||
|
|
||||||
|
hasToolResults() {
|
||||||
|
const tr = this.props.message?.ai_meta?.tool_results
|
||||||
|
return tr && tr.length > 0
|
||||||
|
},
|
||||||
|
|
||||||
|
getToolResults() {
|
||||||
|
return this.props.message?.ai_meta?.tool_results || []
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleToolResult(e) {
|
||||||
|
const toggle = e.currentTarget
|
||||||
|
const body = toggle.nextElementSibling
|
||||||
|
const isOpen = toggle.classList.contains('open')
|
||||||
|
if (isOpen) {
|
||||||
|
toggle.classList.remove('open')
|
||||||
|
body.classList.add('collapsed')
|
||||||
|
} else {
|
||||||
|
toggle.classList.add('open')
|
||||||
|
body.classList.remove('collapsed')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
copyFullMessage(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
const content = this.props.message?.content
|
||||||
|
if (!content) return
|
||||||
|
const btn = e.currentTarget
|
||||||
|
navigator.clipboard.writeText(content).then(() => {
|
||||||
|
btn.classList.add('copied')
|
||||||
|
btn.title = 'Copied!'
|
||||||
|
setTimeout(() => {
|
||||||
|
btn.classList.remove('copied')
|
||||||
|
btn.title = 'Copy response'
|
||||||
|
}, 2000)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</message-bubble>
|
||||||
157
client/src/components/register-page.riot
Normal file
157
client/src/components/register-page.riot
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
<register-page>
|
||||||
|
<div class="auth-page">
|
||||||
|
<div class="auth-card">
|
||||||
|
<div class="auth-header">
|
||||||
|
<h1>GroupChat</h1>
|
||||||
|
<p>Create your account</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onsubmit={handleSubmit}>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="display_name">Display Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="display_name"
|
||||||
|
placeholder="Your name"
|
||||||
|
value={state.display_name}
|
||||||
|
oninput={e => update({ display_name: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="email">Email</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
id="email"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
value={state.email}
|
||||||
|
oninput={e => update({ email: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password">Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="password"
|
||||||
|
placeholder="Choose a password"
|
||||||
|
value={state.password}
|
||||||
|
oninput={e => update({ password: e.target.value })}
|
||||||
|
required
|
||||||
|
minlength="6"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p if={state.error} class="error-text">{state.error}</p>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary btn-full" disabled={state.loading}>
|
||||||
|
{state.loading ? 'Creating account...' : 'Create Account'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p class="auth-footer">
|
||||||
|
Already have an account?
|
||||||
|
<a href="#" onclick={e => { e.preventDefault(); props.cbSwitch() }}>Sign in</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.auth-page {
|
||||||
|
width: 100%;
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
padding: var(--space-2xl);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: var(--space-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-header h1 {
|
||||||
|
font-size: var(--text-2xl);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent);
|
||||||
|
margin-bottom: var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-header p {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-full {
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--space-sm) var(--space-md);
|
||||||
|
margin-top: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-text {
|
||||||
|
color: var(--error);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
margin-bottom: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-footer {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: var(--space-lg);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { api } from '../services/api.js'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
state: {
|
||||||
|
display_name: '',
|
||||||
|
email: '',
|
||||||
|
password: '',
|
||||||
|
error: null,
|
||||||
|
loading: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
async handleSubmit(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
this.update({ loading: true, error: null })
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await api.register({
|
||||||
|
email: this.state.email,
|
||||||
|
password: this.state.password,
|
||||||
|
display_name: this.state.display_name,
|
||||||
|
})
|
||||||
|
this.props.cbRegister(data)
|
||||||
|
} catch (err) {
|
||||||
|
this.update({ error: err.message, loading: false })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</register-page>
|
||||||
27
client/src/main.js
Normal file
27
client/src/main.js
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import { component, register, mount } from 'riot'
|
||||||
|
|
||||||
|
import App from './components/app.riot'
|
||||||
|
import LoginPage from './components/login-page.riot'
|
||||||
|
import RegisterPage from './components/register-page.riot'
|
||||||
|
import ChatSidebar from './components/chat-sidebar.riot'
|
||||||
|
import ChatRoom from './components/chat-room.riot'
|
||||||
|
import CreateRoomModal from './components/create-room-modal.riot'
|
||||||
|
import InviteModal from './components/invite-modal.riot'
|
||||||
|
import DeleteRoomModal from './components/delete-room-modal.riot'
|
||||||
|
import ClearConfirmModal from './components/clear-confirm-modal.riot'
|
||||||
|
import MessageBubble from './components/message-bubble.riot'
|
||||||
|
|
||||||
|
// Register all components
|
||||||
|
register('login-page', LoginPage)
|
||||||
|
register('register-page', RegisterPage)
|
||||||
|
register('chat-sidebar', ChatSidebar)
|
||||||
|
register('chat-room', ChatRoom)
|
||||||
|
register('create-room-modal', CreateRoomModal)
|
||||||
|
register('invite-modal', InviteModal)
|
||||||
|
register('delete-room-modal', DeleteRoomModal)
|
||||||
|
register('clear-confirm-modal', ClearConfirmModal)
|
||||||
|
register('message-bubble', MessageBubble)
|
||||||
|
|
||||||
|
// Mount the app
|
||||||
|
const mountApp = component(App)
|
||||||
|
mountApp(document.getElementById('app'))
|
||||||
83
client/src/services/api.js
Normal file
83
client/src/services/api.js
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
const API_BASE = '/api'
|
||||||
|
|
||||||
|
function getToken() {
|
||||||
|
return localStorage.getItem('token')
|
||||||
|
}
|
||||||
|
|
||||||
|
function authHeaders() {
|
||||||
|
const token = getToken()
|
||||||
|
return token ? { Authorization: `Bearer ${token}` } : {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request(method, path, body) {
|
||||||
|
const opts = {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...authHeaders(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body) {
|
||||||
|
opts.body = JSON.stringify(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE}${path}`, opts)
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text()
|
||||||
|
throw new Error(text || `HTTP ${res.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.status === 204 || res.headers.get('content-length') === '0') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
// Auth
|
||||||
|
register: (data) => request('POST', '/auth/register', data),
|
||||||
|
login: (data) => request('POST', '/auth/login', data),
|
||||||
|
me: () => request('GET', '/auth/me'),
|
||||||
|
|
||||||
|
// Rooms
|
||||||
|
listRooms: () => request('GET', '/rooms'),
|
||||||
|
createRoom: (data) => request('POST', '/rooms', data),
|
||||||
|
getRoom: (roomId) => request('GET', `/rooms/${roomId}`),
|
||||||
|
getMessages: (roomId, limit = 50, before) => {
|
||||||
|
const params = new URLSearchParams({ limit: String(limit) })
|
||||||
|
if (before) params.set('before', before)
|
||||||
|
return request('GET', `/rooms/${roomId}/messages?${params}`)
|
||||||
|
},
|
||||||
|
joinRoom: (roomId) => request('POST', `/rooms/${roomId}/join`),
|
||||||
|
deleteRoom: (roomId) => request('DELETE', `/rooms/${roomId}`),
|
||||||
|
clearRoom: (roomId) => request('POST', `/rooms/${roomId}/clear`),
|
||||||
|
|
||||||
|
// Models
|
||||||
|
listModels: () => request('GET', '/models'),
|
||||||
|
|
||||||
|
// Invites
|
||||||
|
createInvite: (data) => request('POST', '/invites', data),
|
||||||
|
acceptInvite: (token) => request('POST', `/invites/${token}/accept`),
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveAuth(token, user) {
|
||||||
|
localStorage.setItem('token', token)
|
||||||
|
localStorage.setItem('user', JSON.stringify(user))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUser() {
|
||||||
|
const raw = localStorage.getItem('user')
|
||||||
|
return raw ? JSON.parse(raw) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearAuth() {
|
||||||
|
localStorage.removeItem('token')
|
||||||
|
localStorage.removeItem('user')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAuthenticated() {
|
||||||
|
return !!getToken()
|
||||||
|
}
|
||||||
22
client/src/services/markdown.js
Normal file
22
client/src/services/markdown.js
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import MarkdownIt from 'markdown-it'
|
||||||
|
import hljs from 'highlight.js'
|
||||||
|
|
||||||
|
const md = new MarkdownIt({
|
||||||
|
html: false,
|
||||||
|
linkify: true,
|
||||||
|
typographer: true,
|
||||||
|
highlight(str, lang) {
|
||||||
|
if (lang && hljs.getLanguage(lang)) {
|
||||||
|
try {
|
||||||
|
return `<pre class="hljs"><code>${hljs.highlight(str, { language: lang }).value}</code></pre>`
|
||||||
|
} catch (_) {
|
||||||
|
// fall through
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return `<pre class="hljs"><code>${md.utils.escapeHtml(str)}</code></pre>`
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export function renderMarkdown(content) {
|
||||||
|
return md.render(content)
|
||||||
|
}
|
||||||
140
client/src/services/websocket.js
Normal file
140
client/src/services/websocket.js
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
/**
|
||||||
|
* WebSocket manager for real-time chat communication.
|
||||||
|
* Handles connection, reconnection, and message routing.
|
||||||
|
*/
|
||||||
|
class WebSocketManager {
|
||||||
|
constructor() {
|
||||||
|
this.ws = null
|
||||||
|
this.listeners = new Map()
|
||||||
|
this.reconnectTimer = null
|
||||||
|
this.reconnectDelay = 1000
|
||||||
|
this.maxReconnectDelay = 30000
|
||||||
|
this.token = null
|
||||||
|
this.subscribedRooms = new Set()
|
||||||
|
}
|
||||||
|
|
||||||
|
connect(token) {
|
||||||
|
if (this.ws && this.ws.readyState === WebSocket.OPEN) return
|
||||||
|
|
||||||
|
this.token = token
|
||||||
|
|
||||||
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||||
|
const host = window.location.host
|
||||||
|
this.ws = new WebSocket(`${protocol}//${host}/ws?token=${encodeURIComponent(token)}`)
|
||||||
|
|
||||||
|
this.ws.onopen = () => {
|
||||||
|
console.log('[WS] Connected')
|
||||||
|
this.reconnectDelay = 1000
|
||||||
|
|
||||||
|
// Re-subscribe to all rooms we were watching
|
||||||
|
for (const roomId of this.subscribedRooms) {
|
||||||
|
console.log('[WS] Re-joining room:', roomId)
|
||||||
|
this._rawSend({ type: 'join_room', room_id: roomId })
|
||||||
|
}
|
||||||
|
|
||||||
|
this.emit('connected')
|
||||||
|
}
|
||||||
|
|
||||||
|
this.ws.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(event.data)
|
||||||
|
console.log('[WS] Received:', msg.type)
|
||||||
|
this.emit(msg.type, msg)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[WS] Parse error:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.ws.onclose = (event) => {
|
||||||
|
console.log('[WS] Disconnected', event.code)
|
||||||
|
this.emit('disconnected')
|
||||||
|
if (this.token) {
|
||||||
|
this.scheduleReconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.ws.onerror = (error) => {
|
||||||
|
console.error('[WS] Error:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect() {
|
||||||
|
this.token = null
|
||||||
|
this.subscribedRooms.clear()
|
||||||
|
if (this.reconnectTimer) {
|
||||||
|
clearTimeout(this.reconnectTimer)
|
||||||
|
this.reconnectTimer = null
|
||||||
|
}
|
||||||
|
if (this.ws) {
|
||||||
|
this.ws.close()
|
||||||
|
this.ws = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleReconnect() {
|
||||||
|
if (this.reconnectTimer) return
|
||||||
|
console.log(`[WS] Reconnecting in ${this.reconnectDelay}ms...`)
|
||||||
|
this.reconnectTimer = setTimeout(() => {
|
||||||
|
this.reconnectTimer = null
|
||||||
|
this.reconnectDelay = Math.min(this.reconnectDelay * 2, this.maxReconnectDelay)
|
||||||
|
if (this.token) {
|
||||||
|
this.connect(this.token)
|
||||||
|
}
|
||||||
|
}, this.reconnectDelay)
|
||||||
|
}
|
||||||
|
|
||||||
|
_rawSend(data) {
|
||||||
|
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||||
|
this.ws.send(JSON.stringify(data))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
send(data) {
|
||||||
|
if (!this._rawSend(data)) {
|
||||||
|
console.warn('[WS] Cannot send - not connected:', data.type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
joinRoom(roomId) {
|
||||||
|
this.subscribedRooms.add(roomId)
|
||||||
|
this.send({ type: 'join_room', room_id: roomId })
|
||||||
|
}
|
||||||
|
|
||||||
|
sendMessage(roomId, content, mentions = []) {
|
||||||
|
console.log('[WS] Sending message to room:', roomId)
|
||||||
|
this.send({
|
||||||
|
type: 'send_message',
|
||||||
|
room_id: roomId,
|
||||||
|
content,
|
||||||
|
mentions,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
sendTyping(roomId) {
|
||||||
|
// Throttle typing notifications to once per 2 seconds
|
||||||
|
if (this._typingThrottle) return
|
||||||
|
this._typingThrottle = true
|
||||||
|
setTimeout(() => { this._typingThrottle = false }, 2000)
|
||||||
|
this.send({ type: 'typing', room_id: roomId })
|
||||||
|
}
|
||||||
|
|
||||||
|
on(event, callback) {
|
||||||
|
if (!this.listeners.has(event)) {
|
||||||
|
this.listeners.set(event, new Set())
|
||||||
|
}
|
||||||
|
this.listeners.get(event).add(callback)
|
||||||
|
return () => this.listeners.get(event)?.delete(callback)
|
||||||
|
}
|
||||||
|
|
||||||
|
emit(event, data) {
|
||||||
|
const handlers = this.listeners.get(event)
|
||||||
|
if (handlers) {
|
||||||
|
handlers.forEach((cb) => cb(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Singleton instance
|
||||||
|
export const ws = new WebSocketManager()
|
||||||
305
client/src/styles/global.css
Normal file
305
client/src/styles/global.css
Normal file
@ -0,0 +1,305 @@
|
|||||||
|
/* ── CSS Custom Properties / Design Tokens ── */
|
||||||
|
:root {
|
||||||
|
/* Background layers */
|
||||||
|
--bg-primary: #0f0f13;
|
||||||
|
--bg-secondary: #16161d;
|
||||||
|
--bg-tertiary: #1c1c27;
|
||||||
|
--bg-elevated: #22222f;
|
||||||
|
--bg-hover: #2a2a3a;
|
||||||
|
|
||||||
|
/* Text */
|
||||||
|
--text-primary: #e4e4ed;
|
||||||
|
--text-secondary: #9d9daf;
|
||||||
|
--text-muted: #6b6b7b;
|
||||||
|
--text-inverse: #0f0f13;
|
||||||
|
|
||||||
|
/* Accent / Brand */
|
||||||
|
--accent: #6c5ce7;
|
||||||
|
--accent-hover: #7f70f0;
|
||||||
|
--accent-subtle: rgba(108, 92, 231, 0.15);
|
||||||
|
|
||||||
|
/* Status colors */
|
||||||
|
--success: #2ed573;
|
||||||
|
--warning: #ffa502;
|
||||||
|
--error: #ff4757;
|
||||||
|
--info: #3498db;
|
||||||
|
|
||||||
|
/* AI-specific */
|
||||||
|
--ai-bg: rgba(108, 92, 231, 0.08);
|
||||||
|
--ai-border: rgba(108, 92, 231, 0.25);
|
||||||
|
|
||||||
|
/* Borders */
|
||||||
|
--border: #2a2a3a;
|
||||||
|
--border-light: #333346;
|
||||||
|
|
||||||
|
/* Spacing scale */
|
||||||
|
--space-xs: 4px;
|
||||||
|
--space-sm: 8px;
|
||||||
|
--space-md: 16px;
|
||||||
|
--space-lg: 24px;
|
||||||
|
--space-xl: 32px;
|
||||||
|
--space-2xl: 48px;
|
||||||
|
|
||||||
|
/* Typography */
|
||||||
|
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
--font-mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace;
|
||||||
|
--text-xs: 0.75rem;
|
||||||
|
--text-sm: 0.875rem;
|
||||||
|
--text-base: 1rem;
|
||||||
|
--text-lg: 1.125rem;
|
||||||
|
--text-xl: 1.25rem;
|
||||||
|
--text-2xl: 1.5rem;
|
||||||
|
|
||||||
|
/* Radii */
|
||||||
|
--radius-sm: 4px;
|
||||||
|
--radius-md: 8px;
|
||||||
|
--radius-lg: 12px;
|
||||||
|
--radius-full: 9999px;
|
||||||
|
|
||||||
|
/* Shadows */
|
||||||
|
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||||
|
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||||
|
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.5);
|
||||||
|
|
||||||
|
/* Transitions */
|
||||||
|
--transition-fast: 150ms ease;
|
||||||
|
--transition-base: 250ms ease;
|
||||||
|
|
||||||
|
/* Layout */
|
||||||
|
--sidebar-width: 280px;
|
||||||
|
--header-height: 56px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Reset ── */
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
height: 100%;
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 16px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: var(--bg-primary);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
color: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Scrollbar ── */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--border);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Utility classes ── */
|
||||||
|
.sr-only {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Form elements ── */
|
||||||
|
input, textarea, select, button {
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"],
|
||||||
|
input[type="email"],
|
||||||
|
input[type="password"],
|
||||||
|
textarea,
|
||||||
|
select {
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--space-sm) var(--space-md);
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: var(--text-primary);
|
||||||
|
outline: none;
|
||||||
|
transition: border-color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus,
|
||||||
|
textarea:focus,
|
||||||
|
select:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
padding: var(--space-sm) var(--space-lg);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Markdown content styles ── */
|
||||||
|
.markdown-content h1,
|
||||||
|
.markdown-content h2,
|
||||||
|
.markdown-content h3 {
|
||||||
|
margin-top: var(--space-md);
|
||||||
|
margin-bottom: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content p {
|
||||||
|
margin-bottom: var(--space-sm);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content p:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content code {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.9em;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content pre {
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: var(--space-md);
|
||||||
|
padding-top: calc(var(--space-md) + 4px);
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: var(--space-sm) 0;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content pre code {
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-copy-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 6px;
|
||||||
|
right: 6px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 3px 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity var(--transition-fast), color var(--transition-fast), background var(--transition-fast);
|
||||||
|
z-index: 1;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content pre:hover .code-copy-btn {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-copy-btn:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-copy-btn.copied {
|
||||||
|
opacity: 1;
|
||||||
|
color: var(--success);
|
||||||
|
border-color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content ul,
|
||||||
|
.markdown-content ol {
|
||||||
|
padding-left: var(--space-lg);
|
||||||
|
margin-bottom: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content blockquote {
|
||||||
|
border-left: 3px solid var(--accent);
|
||||||
|
padding-left: var(--space-md);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin: var(--space-sm) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content a {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: var(--space-sm) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content th,
|
||||||
|
.markdown-content td {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: var(--space-xs) var(--space-sm);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content th {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
36
client/vite.config.js
Normal file
36
client/vite.config.js
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
|
||||||
|
// Custom Riot.js plugin for Vite
|
||||||
|
function riotPlugin() {
|
||||||
|
return {
|
||||||
|
name: 'vite-plugin-riot',
|
||||||
|
async transform(code, id) {
|
||||||
|
if (!id.endsWith('.riot')) return null
|
||||||
|
|
||||||
|
const { compile } = await import('@riotjs/compiler')
|
||||||
|
const { code: compiled } = compile(code, { file: id })
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: compiled,
|
||||||
|
map: null,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [riotPlugin()],
|
||||||
|
server: {
|
||||||
|
port: 3000,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:3001',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
'/ws': {
|
||||||
|
target: 'ws://localhost:3001',
|
||||||
|
ws: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
95
dev.ps1
Normal file
95
dev.ps1
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
# GroupChat2 Dev Script
|
||||||
|
# Runs both server (Rust/Axum) and client (Vite) with unified console output.
|
||||||
|
# Ctrl+C stops both processes.
|
||||||
|
|
||||||
|
$root = $PSScriptRoot
|
||||||
|
|
||||||
|
# Install client deps if needed
|
||||||
|
if (-not (Test-Path "$root\client\node_modules")) {
|
||||||
|
Write-Host "[dev] Installing client dependencies..." -ForegroundColor Cyan
|
||||||
|
Push-Location "$root\client"
|
||||||
|
& cmd /c "npm install"
|
||||||
|
Pop-Location
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host " GroupChat2 Dev Server" -ForegroundColor Magenta
|
||||||
|
Write-Host " Server: http://localhost:3001" -ForegroundColor Yellow
|
||||||
|
Write-Host " Client: http://localhost:3000" -ForegroundColor Cyan
|
||||||
|
Write-Host " Press Ctrl+C to stop both" -ForegroundColor DarkGray
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# Use PowerShell jobs — they handle .cmd files (npm) properly
|
||||||
|
$serverJob = Start-Job -ScriptBlock {
|
||||||
|
param($dir)
|
||||||
|
Set-Location $dir
|
||||||
|
& cargo run 2>&1
|
||||||
|
} -ArgumentList "$root\server"
|
||||||
|
|
||||||
|
$clientJob = Start-Job -ScriptBlock {
|
||||||
|
param($dir)
|
||||||
|
Set-Location $dir
|
||||||
|
& cmd /c "npm run dev" 2>&1
|
||||||
|
} -ArgumentList "$root\client"
|
||||||
|
|
||||||
|
try {
|
||||||
|
while ($true) {
|
||||||
|
# Pull and print output from both jobs
|
||||||
|
$serverOut = Receive-Job $serverJob 2>&1
|
||||||
|
$clientOut = Receive-Job $clientJob 2>&1
|
||||||
|
|
||||||
|
foreach ($line in $serverOut) {
|
||||||
|
$text = "$line"
|
||||||
|
if ($text.Trim()) {
|
||||||
|
Write-Host "[server] $text" -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($line in $clientOut) {
|
||||||
|
$text = "$line"
|
||||||
|
if ($text.Trim()) {
|
||||||
|
Write-Host "[client] $text" -ForegroundColor Cyan
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check if either job died
|
||||||
|
if ($serverJob.State -eq 'Completed' -or $serverJob.State -eq 'Failed') {
|
||||||
|
Write-Host "[dev] Server process exited ($($serverJob.State))" -ForegroundColor Red
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if ($clientJob.State -eq 'Completed' -or $clientJob.State -eq 'Failed') {
|
||||||
|
Write-Host "[dev] Client process exited ($($clientJob.State))" -ForegroundColor Red
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
Start-Sleep -Milliseconds 300
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "[dev] Shutting down..." -ForegroundColor Red
|
||||||
|
|
||||||
|
# Stop the PS jobs
|
||||||
|
Stop-Job $serverJob -ErrorAction SilentlyContinue
|
||||||
|
Stop-Job $clientJob -ErrorAction SilentlyContinue
|
||||||
|
Remove-Job $serverJob -Force -ErrorAction SilentlyContinue
|
||||||
|
Remove-Job $clientJob -Force -ErrorAction SilentlyContinue
|
||||||
|
|
||||||
|
# Kill any lingering processes by name/port
|
||||||
|
# Kill cargo/rust server
|
||||||
|
Get-Process -Name "groupchat-server" -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue
|
||||||
|
|
||||||
|
# Kill node/vite on port 3000
|
||||||
|
$nodeProcs = Get-NetTCPConnection -LocalPort 3000 -ErrorAction SilentlyContinue | Select-Object -ExpandProperty OwningProcess -Unique
|
||||||
|
foreach ($pid in $nodeProcs) {
|
||||||
|
Stop-Process -Id $pid -Force -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
|
||||||
|
# Kill anything on server port 3001 too
|
||||||
|
$serverProcs = Get-NetTCPConnection -LocalPort 3001 -ErrorAction SilentlyContinue | Select-Object -ExpandProperty OwningProcess -Unique
|
||||||
|
foreach ($pid in $serverProcs) {
|
||||||
|
Stop-Process -Id $pid -Force -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "[dev] Stopped." -ForegroundColor Red
|
||||||
|
}
|
||||||
66
plan.md
Normal file
66
plan.md
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
# Role
|
||||||
|
You are an Expert Full-Stack Developer specializing in Rust (Axum), modern JavaScript (Riot.js), and AI API Integration.
|
||||||
|
|
||||||
|
# Goal
|
||||||
|
Create a complete "Phase 1" implementation plan and the core file structure for a Real-Time Group Chat Application powered by OpenRouter.
|
||||||
|
|
||||||
|
# Project Context
|
||||||
|
This application is a group chat where multiple humans can talk to each other and a single AI assistant.
|
||||||
|
* **Phase 1 (Current Scope):** Humans + 1 System AI in a shared chatroom.
|
||||||
|
* **Phase 2 (Future):** Multiple AI Agents added to the chat.
|
||||||
|
* **Critical Requirement:** The architecture must be event-driven to support the future addition of autonomous agents.
|
||||||
|
|
||||||
|
# Tech Stack Requirements
|
||||||
|
* **Backend:** Rust (using **Axum** framework).
|
||||||
|
* **AI Engine:** **OpenRouter API** (Support for user-selectable models like Claude 3.7, Llama 3, GPT-4, etc.).
|
||||||
|
* **Real-time:** WebSockets (using `tokio-tungstenite` or Axum's built-in `ws` extractor).
|
||||||
|
* **Frontend:** Vite with **Riot.js (v9+)**.
|
||||||
|
* **Database:** SQLite (managed via `sqlx` for async Rust) - keeping it simple for Phase 1 but strongly typed.
|
||||||
|
* **Styling:** **NO FRAMEWORKS.** Use Custom CSS with CSS Variables (Custom Properties) for theming. Leverage Riot.js scoped styles.
|
||||||
|
|
||||||
|
# Functional Requirements & Specification
|
||||||
|
|
||||||
|
1. **User System:**
|
||||||
|
* Simple email/password registration (Argon2 hashing).
|
||||||
|
* JWT-based authentication for HTTP and WebSocket upgrades.
|
||||||
|
* Users can "Invite" others via email (generate a unique link).
|
||||||
|
|
||||||
|
2. **Chat Engine & OpenRouter Integration:**
|
||||||
|
* **Room Creation:** When a user starts a chat, they must be able to select which **OpenRouter Model ID** (e.g., `anthropic/claude-3.7-sonnet`, `meta-llama/llama-3-70b-instruct`) drives that specific room.
|
||||||
|
* **Group Context:** The AI must understand it is in a "Room" with multiple people.
|
||||||
|
* **Message Structure:**
|
||||||
|
* `sender_id` (UUID)
|
||||||
|
* `sender_name` (Display Name)
|
||||||
|
* `timestamp` (UTC)
|
||||||
|
* `content` (Markdown string)
|
||||||
|
* `mentions` (Array of User IDs tagged)
|
||||||
|
* **AI Trigger:** When the AI is mentioned or (optionally) on every message, the backend sends the recent chat history to OpenRouter.
|
||||||
|
|
||||||
|
3. **Frontend (Riot.js):**
|
||||||
|
* Use `.riot` components.
|
||||||
|
* Implement a Markdown renderer (e.g., `markdown-it`) with syntax highlighting (e.g., `highlight.js`).
|
||||||
|
* **Model Selector:** A dropdown in the "Create Chat" modal to pick the OpenRouter model.
|
||||||
|
* **Design System:**
|
||||||
|
* Create a `global.css` for root variables (Dark Mode colors, spacing).
|
||||||
|
* Use Riot's `<style>` tag for component-level, scoped CSS.
|
||||||
|
* The design should be professional, clean, and "Dark Mode" by default.
|
||||||
|
|
||||||
|
# Deliverables Needed From You
|
||||||
|
|
||||||
|
1. **Directory Structure:** A comprehensive ASCII tree of the project structure (separating `server/` and `client/`).
|
||||||
|
2. **Rust `Cargo.toml` dependencies:** The specific crates needed (`reqwest` for OpenRouter API, `axum`, `tokio`, `serde`, `sqlx`, etc.).
|
||||||
|
3. **Database Schema:** The SQL migration code for:
|
||||||
|
* `users`
|
||||||
|
* `rooms` (Must include a `model_id` column to store the chosen OpenRouter model string)
|
||||||
|
* `messages`
|
||||||
|
* `invites`
|
||||||
|
4. **Core Backend Code:**
|
||||||
|
* The WebSocket handler in Axum.
|
||||||
|
* The **OpenRouter Service Function**: A Rust function that takes `(room_history, model_id)` and sends the request to `https://openrouter.ai/api/v1/chat/completions`.
|
||||||
|
* The `Broadcast` loop that sends AI responses back to the room.
|
||||||
|
5. **Core Frontend Code:**
|
||||||
|
* The `vite.config.js` setup for Riot.
|
||||||
|
* The `global.css` file defining the color palette (Professional Dark Mode).
|
||||||
|
* The `chat-room.riot` component demonstrating the message loop, Markdown rendering, and **scoped CSS**.
|
||||||
|
|
||||||
|
Please ignore strict error handling for edge cases to keep the code readable, but ensure concurrency safety in the Rust WebSocket manager. Start by outlining the folder structure.
|
||||||
12
server/.env.example
Normal file
12
server/.env.example
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
# Server
|
||||||
|
BIND_ADDR=0.0.0.0:3001
|
||||||
|
RUST_LOG=info
|
||||||
|
|
||||||
|
# Database
|
||||||
|
DATABASE_URL=sqlite:chat.db?mode=rwc
|
||||||
|
|
||||||
|
# Auth
|
||||||
|
JWT_SECRET=change-me-to-a-random-secret
|
||||||
|
|
||||||
|
# OpenRouter API
|
||||||
|
OPENROUTER_API_KEY=sk-or-v1-your-key-here
|
||||||
3887
server/Cargo.lock
generated
Normal file
3887
server/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
server/Cargo.toml
Normal file
26
server/Cargo.toml
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
[package]
|
||||||
|
name = "groupchat-server"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
axum = { version = "0.7", features = ["ws", "macros"] }
|
||||||
|
axum-extra = { version = "0.9", features = ["typed-header"] }
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
tower = "0.4"
|
||||||
|
tower-http = { version = "0.5", features = ["cors", "fs"] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
sqlx = { version = "0.7", features = ["runtime-tokio", "sqlite", "uuid", "chrono"] }
|
||||||
|
uuid = { version = "1", features = ["v4", "serde"] }
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
jsonwebtoken = "9"
|
||||||
|
argon2 = "0.5"
|
||||||
|
reqwest = { version = "0.12", features = ["json", "stream", "gzip", "brotli", "deflate"] }
|
||||||
|
futures = "0.3"
|
||||||
|
dotenvy = "0.15"
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
rand = "0.8"
|
||||||
|
async-trait = "0.1"
|
||||||
|
scraper = "0.22"
|
||||||
52
server/migrations/001_init.sql
Normal file
52
server/migrations/001_init.sql
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
-- Users table
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
|
email TEXT UNIQUE NOT NULL,
|
||||||
|
display_name TEXT NOT NULL,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Rooms table with model_id for OpenRouter model selection
|
||||||
|
CREATE TABLE IF NOT EXISTS rooms (
|
||||||
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
model_id TEXT NOT NULL DEFAULT 'anthropic/claude-3.5-sonnet',
|
||||||
|
created_by TEXT NOT NULL REFERENCES users(id),
|
||||||
|
ai_always_respond INTEGER NOT NULL DEFAULT 0,
|
||||||
|
system_prompt TEXT NOT NULL DEFAULT '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, respond helpfully.',
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Messages table
|
||||||
|
CREATE TABLE IF NOT EXISTS messages (
|
||||||
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
|
room_id TEXT NOT NULL REFERENCES rooms(id),
|
||||||
|
sender_id TEXT NOT NULL,
|
||||||
|
sender_name TEXT NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
mentions TEXT NOT NULL DEFAULT '[]',
|
||||||
|
is_ai INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_messages_room_id ON messages(room_id, created_at);
|
||||||
|
|
||||||
|
-- Room members join table
|
||||||
|
CREATE TABLE IF NOT EXISTS room_members (
|
||||||
|
room_id TEXT NOT NULL REFERENCES rooms(id),
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id),
|
||||||
|
joined_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
PRIMARY KEY (room_id, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Invites table
|
||||||
|
CREATE TABLE IF NOT EXISTS invites (
|
||||||
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
|
room_id TEXT NOT NULL REFERENCES rooms(id),
|
||||||
|
invited_by TEXT NOT NULL REFERENCES users(id),
|
||||||
|
email TEXT NOT NULL,
|
||||||
|
token TEXT UNIQUE NOT NULL,
|
||||||
|
used INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
2
server/migrations/002_soft_delete.sql
Normal file
2
server/migrations/002_soft_delete.sql
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
-- Add soft-delete support for rooms
|
||||||
|
ALTER TABLE rooms ADD COLUMN deleted_at TEXT NULL;
|
||||||
2
server/migrations/003_ai_meta.sql
Normal file
2
server/migrations/003_ai_meta.sql
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
-- Store AI metadata (model, tokens, speed, tool results) on AI messages
|
||||||
|
ALTER TABLE messages ADD COLUMN ai_meta TEXT NULL;
|
||||||
100
server/src/handlers/auth.rs
Normal file
100
server/src/handlers/auth.rs
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
use axum::{extract::State, http::StatusCode, Json};
|
||||||
|
use argon2::{
|
||||||
|
password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
|
||||||
|
Argon2,
|
||||||
|
};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
middleware::auth::{create_token, AuthUser},
|
||||||
|
models::{AuthResponse, LoginRequest, RegisterRequest, UserPublic},
|
||||||
|
AppState,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub async fn register(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Json(body): Json<RegisterRequest>,
|
||||||
|
) -> Result<Json<AuthResponse>, (StatusCode, String)> {
|
||||||
|
// Check if email already exists
|
||||||
|
let existing = sqlx::query_scalar::<_, String>("SELECT id FROM users WHERE email = ?")
|
||||||
|
.bind(&body.email)
|
||||||
|
.fetch_optional(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
if existing.is_some() {
|
||||||
|
return Err((StatusCode::CONFLICT, "Email already registered".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let user_id = Uuid::new_v4().to_string();
|
||||||
|
let salt = SaltString::generate(&mut OsRng);
|
||||||
|
let password_hash = Argon2::default()
|
||||||
|
.hash_password(body.password.as_bytes(), &salt)
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
sqlx::query("INSERT INTO users (id, email, display_name, password_hash) VALUES (?, ?, ?, ?)")
|
||||||
|
.bind(&user_id)
|
||||||
|
.bind(&body.email)
|
||||||
|
.bind(&body.display_name)
|
||||||
|
.bind(&password_hash)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
let token = create_token(&user_id, &body.email, &body.display_name, &state.jwt_secret)
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(Json(AuthResponse {
|
||||||
|
token,
|
||||||
|
user: UserPublic {
|
||||||
|
id: user_id,
|
||||||
|
email: body.email,
|
||||||
|
display_name: body.display_name,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn login(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Json(body): Json<LoginRequest>,
|
||||||
|
) -> Result<Json<AuthResponse>, (StatusCode, String)> {
|
||||||
|
let user = sqlx::query_as::<_, (String, String, String, String)>(
|
||||||
|
"SELECT id, email, display_name, password_hash FROM users WHERE email = ?",
|
||||||
|
)
|
||||||
|
.bind(&body.email)
|
||||||
|
.fetch_optional(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||||
|
.ok_or((StatusCode::UNAUTHORIZED, "Invalid credentials".into()))?;
|
||||||
|
|
||||||
|
let (user_id, email, display_name, hash) = user;
|
||||||
|
|
||||||
|
let parsed_hash = PasswordHash::new(&hash)
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
Argon2::default()
|
||||||
|
.verify_password(body.password.as_bytes(), &parsed_hash)
|
||||||
|
.map_err(|_| (StatusCode::UNAUTHORIZED, "Invalid credentials".to_string()))?;
|
||||||
|
|
||||||
|
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,
|
||||||
|
display_name,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn me(auth: AuthUser) -> Json<UserPublic> {
|
||||||
|
Json(UserPublic {
|
||||||
|
id: auth.user_id,
|
||||||
|
email: auth.email,
|
||||||
|
display_name: auth.display_name,
|
||||||
|
})
|
||||||
|
}
|
||||||
115
server/src/handlers/invites.rs
Normal file
115
server/src/handlers/invites.rs
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
use axum::{
|
||||||
|
extract::{Path, State},
|
||||||
|
http::StatusCode,
|
||||||
|
Json,
|
||||||
|
};
|
||||||
|
use rand::Rng;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
middleware::auth::AuthUser,
|
||||||
|
models::CreateInviteRequest,
|
||||||
|
AppState,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(serde::Serialize)]
|
||||||
|
pub struct InviteResponse {
|
||||||
|
pub id: String,
|
||||||
|
pub token: String,
|
||||||
|
pub invite_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_invite(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
auth: AuthUser,
|
||||||
|
Json(body): Json<CreateInviteRequest>,
|
||||||
|
) -> Result<Json<InviteResponse>, (StatusCode, String)> {
|
||||||
|
// Verify sender is a member
|
||||||
|
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()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let invite_id = Uuid::new_v4().to_string();
|
||||||
|
let token: String = rand::thread_rng()
|
||||||
|
.sample_iter(&rand::distributions::Alphanumeric)
|
||||||
|
.take(32)
|
||||||
|
.map(char::from)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
sqlx::query("INSERT INTO invites (id, room_id, invited_by, email, token) VALUES (?, ?, ?, ?, ?)")
|
||||||
|
.bind(&invite_id)
|
||||||
|
.bind(&body.room_id)
|
||||||
|
.bind(&auth.user_id)
|
||||||
|
.bind(&body.email)
|
||||||
|
.bind(&token)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(Json(InviteResponse {
|
||||||
|
id: invite_id,
|
||||||
|
token: token.clone(),
|
||||||
|
invite_url: format!("/invite/{}", token),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn accept_invite(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
auth: AuthUser,
|
||||||
|
Path(token): Path<String>,
|
||||||
|
) -> Result<StatusCode, (StatusCode, String)> {
|
||||||
|
let invite = sqlx::query_as::<_, (String, String, bool)>(
|
||||||
|
"SELECT id, room_id, used FROM invites WHERE token = ?",
|
||||||
|
)
|
||||||
|
.bind(&token)
|
||||||
|
.fetch_optional(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||||
|
.ok_or((StatusCode::NOT_FOUND, "Invite not found".into()))?;
|
||||||
|
|
||||||
|
let (invite_id, room_id, used) = invite;
|
||||||
|
|
||||||
|
if used {
|
||||||
|
return Err((StatusCode::GONE, "Invite already used".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify room is not deleted
|
||||||
|
let room_active = sqlx::query_scalar::<_, String>(
|
||||||
|
"SELECT id FROM rooms WHERE id = ? AND deleted_at IS NULL",
|
||||||
|
)
|
||||||
|
.bind(&room_id)
|
||||||
|
.fetch_optional(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
if room_active.is_none() {
|
||||||
|
return Err((StatusCode::GONE, "This room has been deleted".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark invite as used
|
||||||
|
sqlx::query("UPDATE invites SET used = 1 WHERE id = ?")
|
||||||
|
.bind(&invite_id)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
// Add user to room
|
||||||
|
sqlx::query("INSERT OR IGNORE INTO room_members (room_id, user_id) VALUES (?, ?)")
|
||||||
|
.bind(&room_id)
|
||||||
|
.bind(&auth.user_id)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(StatusCode::OK)
|
||||||
|
}
|
||||||
5
server/src/handlers/mod.rs
Normal file
5
server/src/handlers/mod.rs
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
pub mod auth;
|
||||||
|
pub mod invites;
|
||||||
|
pub mod models;
|
||||||
|
pub mod rooms;
|
||||||
|
pub mod ws;
|
||||||
124
server/src/handlers/models.rs
Normal file
124
server/src/handlers/models.rs
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
use axum::{
|
||||||
|
extract::State,
|
||||||
|
http::StatusCode,
|
||||||
|
Json,
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::OnceCell;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
|
use crate::AppState;
|
||||||
|
|
||||||
|
/// Cached model list with expiry.
|
||||||
|
static MODEL_CACHE: OnceCell<Mutex<CachedModels>> = OnceCell::const_new();
|
||||||
|
|
||||||
|
struct CachedModels {
|
||||||
|
models: Vec<ModelInfo>,
|
||||||
|
fetched_at: Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
const CACHE_TTL: Duration = Duration::from_secs(60 * 30); // 30 minutes
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct ModelInfo {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub context_length: Option<u64>,
|
||||||
|
pub pricing_prompt: Option<String>,
|
||||||
|
pub pricing_completion: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct OpenRouterModelsResponse {
|
||||||
|
data: Vec<OpenRouterModel>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct OpenRouterModel {
|
||||||
|
id: String,
|
||||||
|
name: String,
|
||||||
|
context_length: Option<u64>,
|
||||||
|
pricing: Option<OpenRouterPricing>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct OpenRouterPricing {
|
||||||
|
prompt: Option<String>,
|
||||||
|
completion: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_models(api_key: &str) -> Result<Vec<ModelInfo>, String> {
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
|
||||||
|
let response = client
|
||||||
|
.get("https://openrouter.ai/api/v1/models")
|
||||||
|
.header("Authorization", format!("Bearer {}", api_key))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to fetch models: {}", e))?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
let status = response.status();
|
||||||
|
let body = response.text().await.unwrap_or_default();
|
||||||
|
return Err(format!("OpenRouter models API error {}: {}", status, body));
|
||||||
|
}
|
||||||
|
|
||||||
|
let data: OpenRouterModelsResponse = response
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to parse models response: {}", e))?;
|
||||||
|
|
||||||
|
let models: Vec<ModelInfo> = data
|
||||||
|
.data
|
||||||
|
.into_iter()
|
||||||
|
.map(|m| {
|
||||||
|
let pricing = m.pricing.as_ref();
|
||||||
|
ModelInfo {
|
||||||
|
id: m.id,
|
||||||
|
name: m.name,
|
||||||
|
context_length: m.context_length,
|
||||||
|
pricing_prompt: pricing.and_then(|p| p.prompt.clone()),
|
||||||
|
pricing_completion: pricing.and_then(|p| p.completion.clone()),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
tracing::info!("Fetched {} models from OpenRouter", models.len());
|
||||||
|
Ok(models)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_models(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
) -> Result<Json<Vec<ModelInfo>>, (StatusCode, String)> {
|
||||||
|
let cache = MODEL_CACHE
|
||||||
|
.get_or_init(|| async {
|
||||||
|
Mutex::new(CachedModels {
|
||||||
|
models: Vec::new(),
|
||||||
|
fetched_at: Instant::now() - CACHE_TTL - Duration::from_secs(1), // expired
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let mut cached = cache.lock().await;
|
||||||
|
|
||||||
|
if cached.models.is_empty() || cached.fetched_at.elapsed() > CACHE_TTL {
|
||||||
|
match fetch_models(&state.openrouter_key).await {
|
||||||
|
Ok(models) => {
|
||||||
|
cached.models = models;
|
||||||
|
cached.fetched_at = Instant::now();
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
// If we have stale data, return it rather than erroring
|
||||||
|
if !cached.models.is_empty() {
|
||||||
|
tracing::warn!("Failed to refresh models, returning stale cache: {}", e);
|
||||||
|
return Ok(Json(cached.models.clone()));
|
||||||
|
}
|
||||||
|
return Err((StatusCode::BAD_GATEWAY, e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Json(cached.models.clone()))
|
||||||
|
}
|
||||||
314
server/src/handlers/rooms.rs
Normal file
314
server/src/handlers/rooms.rs
Normal file
@ -0,0 +1,314 @@
|
|||||||
|
use axum::{
|
||||||
|
extract::{Path, Query, State},
|
||||||
|
http::StatusCode,
|
||||||
|
Json,
|
||||||
|
};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
middleware::auth::AuthUser,
|
||||||
|
models::{CreateRoomRequest, Message, MessagePayload, PaginationParams, Room, RoomResponse, UserPublic},
|
||||||
|
AppState,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub async fn create_room(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
auth: AuthUser,
|
||||||
|
Json(body): Json<CreateRoomRequest>,
|
||||||
|
) -> Result<Json<RoomResponse>, (StatusCode, String)> {
|
||||||
|
let room_id = Uuid::new_v4().to_string();
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO rooms (id, name, model_id, created_by, ai_always_respond, system_prompt) VALUES (?, ?, ?, ?, ?, ?)",
|
||||||
|
)
|
||||||
|
.bind(&room_id)
|
||||||
|
.bind(&body.name)
|
||||||
|
.bind(&body.model_id)
|
||||||
|
.bind(&auth.user_id)
|
||||||
|
.bind(body.ai_always_respond)
|
||||||
|
.bind(&body.system_prompt)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
// Auto-join creator
|
||||||
|
sqlx::query("INSERT INTO room_members (room_id, user_id) VALUES (?, ?)")
|
||||||
|
.bind(&room_id)
|
||||||
|
.bind(&auth.user_id)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(Json(RoomResponse {
|
||||||
|
id: room_id,
|
||||||
|
name: body.name,
|
||||||
|
model_id: body.model_id,
|
||||||
|
created_by: auth.user_id.clone(),
|
||||||
|
ai_always_respond: body.ai_always_respond,
|
||||||
|
system_prompt: body.system_prompt,
|
||||||
|
created_at: chrono::Utc::now().to_rfc3339(),
|
||||||
|
members: vec![UserPublic {
|
||||||
|
id: auth.user_id,
|
||||||
|
email: auth.email,
|
||||||
|
display_name: auth.display_name,
|
||||||
|
}],
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_rooms(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
auth: AuthUser,
|
||||||
|
) -> Result<Json<Vec<RoomResponse>>, (StatusCode, String)> {
|
||||||
|
let rooms = sqlx::query_as::<_, Room>(
|
||||||
|
"SELECT r.* FROM rooms r JOIN room_members rm ON r.id = rm.room_id WHERE rm.user_id = ? AND r.deleted_at IS NULL ORDER BY r.created_at DESC",
|
||||||
|
)
|
||||||
|
.bind(&auth.user_id)
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
let mut result = Vec::new();
|
||||||
|
for room in rooms {
|
||||||
|
let members = sqlx::query_as::<_, (String, String, String)>(
|
||||||
|
"SELECT u.id, u.email, u.display_name FROM users u JOIN room_members rm ON u.id = rm.user_id WHERE rm.room_id = ?",
|
||||||
|
)
|
||||||
|
.bind(&room.id)
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
result.push(RoomResponse {
|
||||||
|
id: room.id,
|
||||||
|
name: room.name,
|
||||||
|
model_id: room.model_id,
|
||||||
|
created_by: room.created_by,
|
||||||
|
ai_always_respond: room.ai_always_respond,
|
||||||
|
system_prompt: room.system_prompt,
|
||||||
|
created_at: room.created_at,
|
||||||
|
members: members
|
||||||
|
.into_iter()
|
||||||
|
.map(|(id, email, display_name)| UserPublic {
|
||||||
|
id,
|
||||||
|
email,
|
||||||
|
display_name,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Json(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_room(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
auth: AuthUser,
|
||||||
|
Path(room_id): Path<String>,
|
||||||
|
) -> Result<Json<RoomResponse>, (StatusCode, String)> {
|
||||||
|
// Verify membership
|
||||||
|
let is_member = sqlx::query_scalar::<_, String>(
|
||||||
|
"SELECT user_id FROM room_members WHERE room_id = ? AND user_id = ?",
|
||||||
|
)
|
||||||
|
.bind(&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()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let room = sqlx::query_as::<_, Room>("SELECT * FROM rooms WHERE id = ? AND deleted_at IS NULL")
|
||||||
|
.bind(&room_id)
|
||||||
|
.fetch_optional(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||||
|
.ok_or((StatusCode::NOT_FOUND, "Room not found".into()))?;
|
||||||
|
|
||||||
|
let members = sqlx::query_as::<_, (String, String, String)>(
|
||||||
|
"SELECT u.id, u.email, u.display_name FROM users u JOIN room_members rm ON u.id = rm.user_id WHERE rm.room_id = ?",
|
||||||
|
)
|
||||||
|
.bind(&room_id)
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(Json(RoomResponse {
|
||||||
|
id: room.id,
|
||||||
|
name: room.name,
|
||||||
|
model_id: room.model_id,
|
||||||
|
created_by: room.created_by,
|
||||||
|
ai_always_respond: room.ai_always_respond,
|
||||||
|
system_prompt: room.system_prompt,
|
||||||
|
created_at: room.created_at,
|
||||||
|
members: members
|
||||||
|
.into_iter()
|
||||||
|
.map(|(id, email, display_name)| UserPublic {
|
||||||
|
id,
|
||||||
|
email,
|
||||||
|
display_name,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_messages(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
auth: AuthUser,
|
||||||
|
Path(room_id): Path<String>,
|
||||||
|
Query(params): Query<PaginationParams>,
|
||||||
|
) -> Result<Json<Vec<MessagePayload>>, (StatusCode, String)> {
|
||||||
|
// Verify membership and room not deleted
|
||||||
|
let is_member = sqlx::query_scalar::<_, String>(
|
||||||
|
"SELECT rm.user_id FROM room_members rm JOIN rooms r ON r.id = rm.room_id WHERE rm.room_id = ? AND rm.user_id = ? AND r.deleted_at IS NULL",
|
||||||
|
)
|
||||||
|
.bind(&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()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let messages = if let Some(before) = ¶ms.before {
|
||||||
|
sqlx::query_as::<_, Message>(
|
||||||
|
"SELECT * FROM messages WHERE room_id = ? AND created_at < ? ORDER BY created_at DESC LIMIT ?",
|
||||||
|
)
|
||||||
|
.bind(&room_id)
|
||||||
|
.bind(before)
|
||||||
|
.bind(params.limit)
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await
|
||||||
|
} else {
|
||||||
|
sqlx::query_as::<_, Message>(
|
||||||
|
"SELECT * FROM messages WHERE room_id = ? ORDER BY created_at DESC LIMIT ?",
|
||||||
|
)
|
||||||
|
.bind(&room_id)
|
||||||
|
.bind(params.limit)
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
let payloads: Vec<MessagePayload> = messages
|
||||||
|
.into_iter()
|
||||||
|
.rev()
|
||||||
|
.map(|m| {
|
||||||
|
let ai_meta = m.ai_meta
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|s| serde_json::from_str::<crate::models::AiMeta>(s).ok());
|
||||||
|
MessagePayload {
|
||||||
|
id: m.id,
|
||||||
|
room_id: m.room_id,
|
||||||
|
sender_id: m.sender_id,
|
||||||
|
sender_name: m.sender_name,
|
||||||
|
content: m.content,
|
||||||
|
mentions: serde_json::from_str(&m.mentions).unwrap_or_default(),
|
||||||
|
is_ai: m.is_ai,
|
||||||
|
created_at: m.created_at,
|
||||||
|
ai_meta,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(Json(payloads))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn join_room(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
auth: AuthUser,
|
||||||
|
Path(room_id): Path<String>,
|
||||||
|
) -> Result<StatusCode, (StatusCode, String)> {
|
||||||
|
// Check room exists
|
||||||
|
let room_exists = sqlx::query_scalar::<_, String>("SELECT id FROM rooms WHERE id = ? AND deleted_at IS NULL")
|
||||||
|
.bind(&room_id)
|
||||||
|
.fetch_optional(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
if room_exists.is_none() {
|
||||||
|
return Err((StatusCode::NOT_FOUND, "Room not found".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlx::query("INSERT OR IGNORE INTO room_members (room_id, user_id) VALUES (?, ?)")
|
||||||
|
.bind(&room_id)
|
||||||
|
.bind(&auth.user_id)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(StatusCode::OK)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_room(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
auth: AuthUser,
|
||||||
|
Path(room_id): Path<String>,
|
||||||
|
) -> Result<StatusCode, (StatusCode, String)> {
|
||||||
|
// Fetch room and verify ownership
|
||||||
|
let room = sqlx::query_as::<_, Room>("SELECT * FROM rooms WHERE id = ? AND deleted_at IS NULL")
|
||||||
|
.bind(&room_id)
|
||||||
|
.fetch_optional(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||||
|
.ok_or((StatusCode::NOT_FOUND, "Room not found".into()))?;
|
||||||
|
|
||||||
|
if room.created_by != auth.user_id {
|
||||||
|
return Err((StatusCode::FORBIDDEN, "Only the room creator can delete this room".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Soft-delete
|
||||||
|
sqlx::query("UPDATE rooms SET deleted_at = datetime('now') WHERE id = ?")
|
||||||
|
.bind(&room_id)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
tracing::info!("Room {} soft-deleted by {}", room_id, auth.user_id);
|
||||||
|
|
||||||
|
// Broadcast to all subscribers
|
||||||
|
let _ = state.tx.send(crate::models::BroadcastEvent {
|
||||||
|
room_id: room_id.clone(),
|
||||||
|
message: crate::models::WsServerMessage::RoomDeleted { room_id },
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(StatusCode::OK)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn clear_room(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
auth: AuthUser,
|
||||||
|
Path(room_id): Path<String>,
|
||||||
|
) -> Result<StatusCode, (StatusCode, String)> {
|
||||||
|
// Verify room exists and not deleted
|
||||||
|
let room = sqlx::query_as::<_, Room>("SELECT * FROM rooms WHERE id = ? AND deleted_at IS NULL")
|
||||||
|
.bind(&room_id)
|
||||||
|
.fetch_optional(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||||
|
.ok_or((StatusCode::NOT_FOUND, "Room not found".into()))?;
|
||||||
|
|
||||||
|
if room.created_by != auth.user_id {
|
||||||
|
return Err((StatusCode::FORBIDDEN, "Only the room creator can clear messages".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hard-delete all messages
|
||||||
|
sqlx::query("DELETE FROM messages WHERE room_id = ?")
|
||||||
|
.bind(&room_id)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
tracing::info!("Room {} messages cleared by {}", room_id, auth.user_id);
|
||||||
|
|
||||||
|
// Broadcast to all subscribers
|
||||||
|
let _ = state.tx.send(crate::models::BroadcastEvent {
|
||||||
|
room_id: room_id.clone(),
|
||||||
|
message: crate::models::WsServerMessage::RoomCleared { room_id },
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(StatusCode::OK)
|
||||||
|
}
|
||||||
430
server/src/handlers/ws.rs
Normal file
430
server/src/handlers/ws.rs
Normal file
@ -0,0 +1,430 @@
|
|||||||
|
use axum::{
|
||||||
|
extract::{
|
||||||
|
ws::{Message, WebSocket},
|
||||||
|
Query, State, WebSocketUpgrade,
|
||||||
|
},
|
||||||
|
response::IntoResponse,
|
||||||
|
};
|
||||||
|
use futures::{SinkExt, StreamExt};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
middleware::auth::decode_token,
|
||||||
|
models::{BroadcastEvent, MessagePayload, WsClientMessage, WsServerMessage},
|
||||||
|
services::{brave, fetch, openrouter},
|
||||||
|
AppState,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Maximum number of tool call rounds before forcing a text response.
|
||||||
|
const MAX_TOOL_ROUNDS: usize = 5;
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
pub struct WsQuery {
|
||||||
|
token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn ws_handler(
|
||||||
|
ws: WebSocketUpgrade,
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Query(query): Query<WsQuery>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
// Authenticate before upgrading
|
||||||
|
let claims = match decode_token(&query.token, &state.jwt_secret) {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(_) => {
|
||||||
|
return axum::http::StatusCode::UNAUTHORIZED.into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.on_upgrade(move |socket| handle_socket(socket, state, claims.sub, claims.display_name))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_socket(socket: WebSocket, state: Arc<AppState>, user_id: String, display_name: String) {
|
||||||
|
let (mut ws_tx, mut ws_rx) = socket.split();
|
||||||
|
let mut broadcast_rx = state.tx.subscribe();
|
||||||
|
|
||||||
|
// Track which rooms this user is watching
|
||||||
|
let subscribed_rooms: Arc<tokio::sync::Mutex<std::collections::HashSet<String>>> =
|
||||||
|
Arc::new(tokio::sync::Mutex::new(std::collections::HashSet::new()));
|
||||||
|
|
||||||
|
let rooms_clone = subscribed_rooms.clone();
|
||||||
|
|
||||||
|
// Task: forward broadcast events to this client
|
||||||
|
let mut send_task = tokio::spawn(async move {
|
||||||
|
while let Ok(event) = broadcast_rx.recv().await {
|
||||||
|
let rooms = rooms_clone.lock().await;
|
||||||
|
if rooms.contains(&event.room_id) {
|
||||||
|
let msg = serde_json::to_string(&event.message).unwrap();
|
||||||
|
if ws_tx.send(Message::Text(msg.into())).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let state_clone = state.clone();
|
||||||
|
let user_id_clone = user_id.clone();
|
||||||
|
let display_name_clone = display_name.clone();
|
||||||
|
let rooms_clone2 = subscribed_rooms.clone();
|
||||||
|
|
||||||
|
// Task: receive messages from client
|
||||||
|
let mut recv_task = tokio::spawn(async move {
|
||||||
|
while let Some(Ok(msg)) = ws_rx.next().await {
|
||||||
|
let text = match msg {
|
||||||
|
Message::Text(t) => t.to_string(),
|
||||||
|
Message::Close(_) => break,
|
||||||
|
_ => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
let client_msg: WsClientMessage = match serde_json::from_str(&text) {
|
||||||
|
Ok(m) => m,
|
||||||
|
Err(e) => {
|
||||||
|
let _ = state_clone.tx.send(BroadcastEvent {
|
||||||
|
room_id: String::new(),
|
||||||
|
message: WsServerMessage::Error {
|
||||||
|
message: format!("Invalid message: {}", e),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match client_msg {
|
||||||
|
WsClientMessage::JoinRoom { room_id } => {
|
||||||
|
tracing::info!("User {} joined room {}", user_id_clone, room_id);
|
||||||
|
rooms_clone2.lock().await.insert(room_id.clone());
|
||||||
|
}
|
||||||
|
WsClientMessage::Typing { room_id } => {
|
||||||
|
let _ = state_clone.tx.send(BroadcastEvent {
|
||||||
|
room_id: room_id.clone(),
|
||||||
|
message: WsServerMessage::UserTyping {
|
||||||
|
room_id,
|
||||||
|
user_id: user_id_clone.clone(),
|
||||||
|
display_name: display_name_clone.clone(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
WsClientMessage::SendMessage {
|
||||||
|
room_id,
|
||||||
|
content,
|
||||||
|
mentions,
|
||||||
|
} => {
|
||||||
|
tracing::info!("User {} sending message to room {}", user_id_clone, room_id);
|
||||||
|
handle_send_message(
|
||||||
|
&state_clone,
|
||||||
|
&user_id_clone,
|
||||||
|
&display_name_clone,
|
||||||
|
&room_id,
|
||||||
|
&content,
|
||||||
|
&mentions,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for either task to finish, then abort the other
|
||||||
|
tokio::select! {
|
||||||
|
_ = &mut send_task => recv_task.abort(),
|
||||||
|
_ = &mut recv_task => send_task.abort(),
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!("WebSocket disconnected: {}", user_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_send_message(
|
||||||
|
state: &Arc<AppState>,
|
||||||
|
user_id: &str,
|
||||||
|
display_name: &str,
|
||||||
|
room_id: &str,
|
||||||
|
content: &str,
|
||||||
|
mentions: &[String],
|
||||||
|
) {
|
||||||
|
let msg_id = Uuid::new_v4().to_string();
|
||||||
|
let mentions_json = serde_json::to_string(mentions).unwrap_or_else(|_| "[]".to_string());
|
||||||
|
let now = chrono::Utc::now().to_rfc3339();
|
||||||
|
|
||||||
|
// Store in database
|
||||||
|
let _ = sqlx::query(
|
||||||
|
"INSERT INTO messages (id, room_id, sender_id, sender_name, content, mentions, is_ai, created_at) VALUES (?, ?, ?, ?, ?, ?, 0, ?)",
|
||||||
|
)
|
||||||
|
.bind(&msg_id)
|
||||||
|
.bind(room_id)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(display_name)
|
||||||
|
.bind(content)
|
||||||
|
.bind(&mentions_json)
|
||||||
|
.bind(&now)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Broadcast human message
|
||||||
|
let payload = MessagePayload {
|
||||||
|
id: msg_id,
|
||||||
|
room_id: room_id.to_string(),
|
||||||
|
sender_id: user_id.to_string(),
|
||||||
|
sender_name: display_name.to_string(),
|
||||||
|
content: content.to_string(),
|
||||||
|
mentions: mentions.to_vec(),
|
||||||
|
is_ai: false,
|
||||||
|
created_at: now,
|
||||||
|
ai_meta: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let _ = state.tx.send(BroadcastEvent {
|
||||||
|
room_id: room_id.to_string(),
|
||||||
|
message: WsServerMessage::NewMessage {
|
||||||
|
message: payload,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if AI should respond
|
||||||
|
let ai_user_id = "ai-assistant";
|
||||||
|
let should_respond = mentions.contains(&ai_user_id.to_string());
|
||||||
|
|
||||||
|
// Also check room settings for ai_always_respond
|
||||||
|
let room = sqlx::query_as::<_, (String, bool, String)>(
|
||||||
|
"SELECT model_id, ai_always_respond, system_prompt FROM rooms WHERE id = ? AND deleted_at IS NULL",
|
||||||
|
)
|
||||||
|
.bind(room_id)
|
||||||
|
.fetch_optional(&state.db)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let (model_id, always_respond, system_prompt) = match room {
|
||||||
|
Ok(Some(r)) => r,
|
||||||
|
_ => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
if !should_respond && !always_respond {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Signal AI is typing
|
||||||
|
let _ = state.tx.send(BroadcastEvent {
|
||||||
|
room_id: room_id.to_string(),
|
||||||
|
message: WsServerMessage::AiTyping {
|
||||||
|
room_id: room_id.to_string(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch recent history
|
||||||
|
let recent_messages = sqlx::query_as::<_, (String, String, bool)>(
|
||||||
|
"SELECT sender_name, content, is_ai FROM messages WHERE room_id = ? ORDER BY created_at DESC LIMIT 50",
|
||||||
|
)
|
||||||
|
.bind(room_id)
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let history: Vec<(String, String, bool)> = recent_messages.into_iter().rev().collect();
|
||||||
|
let mut chat_history = openrouter::build_chat_history(&system_prompt, &history);
|
||||||
|
|
||||||
|
// Build tools for AI
|
||||||
|
let tools = openrouter::build_tools();
|
||||||
|
|
||||||
|
// Call OpenRouter with tool loop
|
||||||
|
let mut total_prompt_tokens: u32 = 0;
|
||||||
|
let mut total_completion_tokens: u32 = 0;
|
||||||
|
let mut total_response_ms: u64 = 0;
|
||||||
|
let mut final_model = model_id.clone();
|
||||||
|
let mut ai_response = String::new();
|
||||||
|
let mut had_error = false;
|
||||||
|
let mut collected_tool_results: Vec<crate::models::ToolResult> = vec![];
|
||||||
|
|
||||||
|
for round in 0..MAX_TOOL_ROUNDS {
|
||||||
|
let result = openrouter::chat_completion(
|
||||||
|
chat_history.clone(),
|
||||||
|
&model_id,
|
||||||
|
&state.openrouter_key,
|
||||||
|
Some(tools.clone()),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(openrouter::ChatCompletionResult::Response(text, stats)) => {
|
||||||
|
// Final text response — done!
|
||||||
|
total_prompt_tokens += stats.prompt_tokens;
|
||||||
|
total_completion_tokens += stats.completion_tokens;
|
||||||
|
total_response_ms += stats.response_ms;
|
||||||
|
final_model = stats.model;
|
||||||
|
ai_response = text;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Ok(openrouter::ChatCompletionResult::ToolCalls(assistant_msg, stats)) => {
|
||||||
|
total_prompt_tokens += stats.prompt_tokens;
|
||||||
|
total_completion_tokens += stats.completion_tokens;
|
||||||
|
total_response_ms += stats.response_ms;
|
||||||
|
final_model = stats.model.clone();
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
"AI requesting tool calls (round {}): {:?}",
|
||||||
|
round + 1,
|
||||||
|
assistant_msg.tool_calls.as_ref().map(|tc| tc.iter().map(|t| &t.function.name).collect::<Vec<_>>())
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add the assistant's tool-call message to history
|
||||||
|
let tool_calls = assistant_msg.tool_calls.clone().unwrap_or_default();
|
||||||
|
chat_history.push(assistant_msg);
|
||||||
|
|
||||||
|
// Execute each tool call and add results
|
||||||
|
for tool_call in &tool_calls {
|
||||||
|
// Extract tool input for display purposes
|
||||||
|
let tool_input = extract_tool_input(&tool_call.function.name, &tool_call.function.arguments);
|
||||||
|
|
||||||
|
// Broadcast real-time tool usage event
|
||||||
|
let _ = state.tx.send(BroadcastEvent {
|
||||||
|
room_id: room_id.to_string(),
|
||||||
|
message: WsServerMessage::AiToolUsage {
|
||||||
|
room_id: room_id.to_string(),
|
||||||
|
tool_name: tool_call.function.name.clone(),
|
||||||
|
status: "calling".to_string(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let tool_result = execute_tool(
|
||||||
|
&tool_call.function.name,
|
||||||
|
&tool_call.function.arguments,
|
||||||
|
&state.brave_api_key,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
"Tool {} result: {} chars",
|
||||||
|
tool_call.function.name,
|
||||||
|
tool_result.len()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Collect tool result for inclusion in final message
|
||||||
|
collected_tool_results.push(crate::models::ToolResult {
|
||||||
|
tool: tool_call.function.name.clone(),
|
||||||
|
input: tool_input,
|
||||||
|
result: tool_result.clone(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add tool result to history
|
||||||
|
chat_history.push(openrouter::ChatMessage {
|
||||||
|
role: "tool".into(),
|
||||||
|
content: Some(tool_result),
|
||||||
|
tool_calls: None,
|
||||||
|
tool_call_id: Some(tool_call.id.clone()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Loop continues — call OpenRouter again with tool results
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("OpenRouter error (round {}): {}", round + 1, e);
|
||||||
|
ai_response = format!("*Sorry, I encountered an error: {}*", e);
|
||||||
|
had_error = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we exhausted all rounds without a text response, note it
|
||||||
|
if ai_response.is_empty() && !had_error {
|
||||||
|
ai_response = "*I used several tools but couldn't formulate a final response. Please try again.*".to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
let ai_meta = if !had_error {
|
||||||
|
Some(crate::models::AiMeta {
|
||||||
|
model: final_model,
|
||||||
|
prompt_tokens: total_prompt_tokens,
|
||||||
|
completion_tokens: total_completion_tokens,
|
||||||
|
total_tokens: total_prompt_tokens + total_completion_tokens,
|
||||||
|
response_ms: total_response_ms,
|
||||||
|
tool_results: if collected_tool_results.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(collected_tool_results)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
// Store AI response
|
||||||
|
let ai_msg_id = Uuid::new_v4().to_string();
|
||||||
|
let ai_now = chrono::Utc::now().to_rfc3339();
|
||||||
|
|
||||||
|
// Serialize ai_meta for database storage
|
||||||
|
let ai_meta_json = ai_meta.as_ref().and_then(|m| serde_json::to_string(m).ok());
|
||||||
|
|
||||||
|
let _ = sqlx::query(
|
||||||
|
"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(room_id)
|
||||||
|
.bind(ai_user_id)
|
||||||
|
.bind("AI Assistant")
|
||||||
|
.bind(&ai_response)
|
||||||
|
.bind(&ai_now)
|
||||||
|
.bind(&ai_meta_json)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Broadcast AI message
|
||||||
|
let ai_payload = MessagePayload {
|
||||||
|
id: ai_msg_id,
|
||||||
|
room_id: room_id.to_string(),
|
||||||
|
sender_id: ai_user_id.to_string(),
|
||||||
|
sender_name: "AI Assistant".to_string(),
|
||||||
|
content: ai_response,
|
||||||
|
mentions: vec![],
|
||||||
|
is_ai: true,
|
||||||
|
created_at: ai_now,
|
||||||
|
ai_meta,
|
||||||
|
};
|
||||||
|
|
||||||
|
let _ = state.tx.send(BroadcastEvent {
|
||||||
|
room_id: room_id.to_string(),
|
||||||
|
message: WsServerMessage::NewMessage {
|
||||||
|
message: ai_payload,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract a human-readable input string from tool arguments (for UI display).
|
||||||
|
fn extract_tool_input(tool_name: &str, arguments: &str) -> String {
|
||||||
|
let args: serde_json::Value = serde_json::from_str(arguments).unwrap_or_default();
|
||||||
|
match tool_name {
|
||||||
|
"brave_search" => args["query"].as_str().unwrap_or("").to_string(),
|
||||||
|
"web_fetch" => args["url"].as_str().unwrap_or("").to_string(),
|
||||||
|
_ => arguments.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Execute a tool call by name, returning the result as a string.
|
||||||
|
async fn execute_tool(name: &str, arguments: &str, brave_api_key: &str) -> String {
|
||||||
|
match name {
|
||||||
|
"brave_search" => {
|
||||||
|
let args: serde_json::Value = serde_json::from_str(arguments).unwrap_or_default();
|
||||||
|
let query = args["query"].as_str().unwrap_or("").to_string();
|
||||||
|
let count = args["count"].as_u64().unwrap_or(5) as u8;
|
||||||
|
|
||||||
|
if query.is_empty() {
|
||||||
|
return "Error: search query is required".into();
|
||||||
|
}
|
||||||
|
|
||||||
|
match brave::search(&query, brave_api_key, count).await {
|
||||||
|
Ok(results) => brave::format_results(&results),
|
||||||
|
Err(e) => format!("Search error: {}", e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"web_fetch" => {
|
||||||
|
let args: serde_json::Value = serde_json::from_str(arguments).unwrap_or_default();
|
||||||
|
let url = args["url"].as_str().unwrap_or("").to_string();
|
||||||
|
|
||||||
|
if url.is_empty() {
|
||||||
|
return "Error: URL is required".into();
|
||||||
|
}
|
||||||
|
|
||||||
|
match fetch::fetch_url(&url, 8000).await {
|
||||||
|
Ok(result) => fetch::format_result(&result),
|
||||||
|
Err(e) => format!("Fetch error: {}", e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => format!("Unknown tool: {}", name),
|
||||||
|
}
|
||||||
|
}
|
||||||
115
server/src/main.rs
Normal file
115
server/src/main.rs
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
mod handlers;
|
||||||
|
mod middleware;
|
||||||
|
mod models;
|
||||||
|
mod services;
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
routing::{get, post},
|
||||||
|
Router,
|
||||||
|
};
|
||||||
|
use sqlx::sqlite::SqlitePoolOptions;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::broadcast;
|
||||||
|
use tower_http::cors::{Any, CorsLayer};
|
||||||
|
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||||
|
|
||||||
|
pub struct AppState {
|
||||||
|
pub db: sqlx::SqlitePool,
|
||||||
|
pub jwt_secret: String,
|
||||||
|
pub openrouter_key: String,
|
||||||
|
pub brave_api_key: String,
|
||||||
|
pub tx: broadcast::Sender<models::BroadcastEvent>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
dotenvy::dotenv().ok();
|
||||||
|
|
||||||
|
tracing_subscriber::registry()
|
||||||
|
.with(tracing_subscriber::EnvFilter::new(
|
||||||
|
std::env::var("RUST_LOG").unwrap_or_else(|_| "info".into()),
|
||||||
|
))
|
||||||
|
.with(tracing_subscriber::fmt::layer())
|
||||||
|
.init();
|
||||||
|
|
||||||
|
let database_url = std::env::var("DATABASE_URL").unwrap_or_else(|_| "sqlite:chat.db?mode=rwc".into());
|
||||||
|
let jwt_secret = std::env::var("JWT_SECRET").unwrap_or_else(|_| "dev-secret-change-me".into());
|
||||||
|
let openrouter_key = std::env::var("OPENROUTER_API_KEY").expect("OPENROUTER_API_KEY must be set");
|
||||||
|
let brave_api_key = std::env::var("BRAVE_API_KEY").expect("BRAVE_API_KEY must be set");
|
||||||
|
|
||||||
|
let db = SqlitePoolOptions::new()
|
||||||
|
.max_connections(5)
|
||||||
|
.connect(&database_url)
|
||||||
|
.await
|
||||||
|
.expect("Failed to connect to database");
|
||||||
|
|
||||||
|
// Run migrations
|
||||||
|
let migration_sql = include_str!("../migrations/001_init.sql");
|
||||||
|
sqlx::raw_sql(migration_sql)
|
||||||
|
.execute(&db)
|
||||||
|
.await
|
||||||
|
.expect("Failed to run migrations");
|
||||||
|
|
||||||
|
// Run migration 002 - soft delete
|
||||||
|
let migration_002 = include_str!("../migrations/002_soft_delete.sql");
|
||||||
|
match sqlx::raw_sql(migration_002).execute(&db).await {
|
||||||
|
Ok(_) => tracing::info!("Migration 002 applied"),
|
||||||
|
Err(e) if e.to_string().contains("duplicate column") => {
|
||||||
|
tracing::debug!("Migration 002 already applied, skipping");
|
||||||
|
}
|
||||||
|
Err(e) => panic!("Failed to run migration 002: {}", e),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run migration 003 - ai_meta on messages
|
||||||
|
let migration_003 = include_str!("../migrations/003_ai_meta.sql");
|
||||||
|
match sqlx::raw_sql(migration_003).execute(&db).await {
|
||||||
|
Ok(_) => tracing::info!("Migration 003 applied"),
|
||||||
|
Err(e) if e.to_string().contains("duplicate column") => {
|
||||||
|
tracing::debug!("Migration 003 already applied, skipping");
|
||||||
|
}
|
||||||
|
Err(e) => panic!("Failed to run migration 003: {}", e),
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!("Database initialized");
|
||||||
|
|
||||||
|
let (tx, _rx) = broadcast::channel::<models::BroadcastEvent>(256);
|
||||||
|
|
||||||
|
let state = Arc::new(AppState {
|
||||||
|
db,
|
||||||
|
jwt_secret,
|
||||||
|
openrouter_key,
|
||||||
|
brave_api_key,
|
||||||
|
tx,
|
||||||
|
});
|
||||||
|
|
||||||
|
let cors = CorsLayer::new()
|
||||||
|
.allow_origin(Any)
|
||||||
|
.allow_methods(Any)
|
||||||
|
.allow_headers(Any);
|
||||||
|
|
||||||
|
let app = Router::new()
|
||||||
|
// Auth routes
|
||||||
|
.route("/api/auth/register", post(handlers::auth::register))
|
||||||
|
.route("/api/auth/login", post(handlers::auth::login))
|
||||||
|
.route("/api/auth/me", get(handlers::auth::me))
|
||||||
|
// Room routes
|
||||||
|
.route("/api/rooms", get(handlers::rooms::list_rooms).post(handlers::rooms::create_room))
|
||||||
|
.route("/api/rooms/:room_id", get(handlers::rooms::get_room).delete(handlers::rooms::delete_room))
|
||||||
|
.route("/api/rooms/:room_id/messages", get(handlers::rooms::get_messages))
|
||||||
|
.route("/api/rooms/:room_id/join", post(handlers::rooms::join_room))
|
||||||
|
.route("/api/rooms/:room_id/clear", post(handlers::rooms::clear_room))
|
||||||
|
// Models
|
||||||
|
.route("/api/models", get(handlers::models::list_models))
|
||||||
|
// Invite routes
|
||||||
|
.route("/api/invites", post(handlers::invites::create_invite))
|
||||||
|
.route("/api/invites/:token/accept", post(handlers::invites::accept_invite))
|
||||||
|
// WebSocket
|
||||||
|
.route("/ws", get(handlers::ws::ws_handler))
|
||||||
|
.layer(cors)
|
||||||
|
.with_state(state);
|
||||||
|
|
||||||
|
let addr = std::env::var("BIND_ADDR").unwrap_or_else(|_| "0.0.0.0:3001".into());
|
||||||
|
tracing::info!("Server starting on {}", addr);
|
||||||
|
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
|
||||||
|
axum::serve(listener, app).await.unwrap();
|
||||||
|
}
|
||||||
71
server/src/middleware/auth.rs
Normal file
71
server/src/middleware/auth.rs
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
use axum::{
|
||||||
|
extract::FromRequestParts,
|
||||||
|
http::request::Parts,
|
||||||
|
};
|
||||||
|
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crate::{models::Claims, AppState};
|
||||||
|
|
||||||
|
/// Extract authenticated user from JWT in Authorization header
|
||||||
|
pub struct AuthUser {
|
||||||
|
pub user_id: String,
|
||||||
|
pub email: String,
|
||||||
|
pub display_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl FromRequestParts<Arc<AppState>> for AuthUser {
|
||||||
|
type Rejection = axum::http::StatusCode;
|
||||||
|
|
||||||
|
async fn from_request_parts(parts: &mut Parts, state: &Arc<AppState>) -> Result<Self, Self::Rejection> {
|
||||||
|
let auth_header = parts
|
||||||
|
.headers
|
||||||
|
.get("Authorization")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||||
|
|
||||||
|
let token = auth_header
|
||||||
|
.strip_prefix("Bearer ")
|
||||||
|
.ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||||
|
|
||||||
|
let claims = decode_token(token, &state.jwt_secret)
|
||||||
|
.map_err(|_| axum::http::StatusCode::UNAUTHORIZED)?;
|
||||||
|
|
||||||
|
Ok(AuthUser {
|
||||||
|
user_id: claims.sub,
|
||||||
|
email: claims.email,
|
||||||
|
display_name: claims.display_name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_token(user_id: &str, email: &str, display_name: &str, secret: &str) -> Result<String, jsonwebtoken::errors::Error> {
|
||||||
|
let expiration = chrono::Utc::now()
|
||||||
|
.checked_add_signed(chrono::Duration::days(7))
|
||||||
|
.unwrap()
|
||||||
|
.timestamp() as usize;
|
||||||
|
|
||||||
|
let claims = Claims {
|
||||||
|
sub: user_id.to_string(),
|
||||||
|
email: email.to_string(),
|
||||||
|
display_name: display_name.to_string(),
|
||||||
|
exp: expiration,
|
||||||
|
};
|
||||||
|
|
||||||
|
encode(
|
||||||
|
&Header::default(),
|
||||||
|
&claims,
|
||||||
|
&EncodingKey::from_secret(secret.as_bytes()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn decode_token(token: &str, secret: &str) -> Result<Claims, jsonwebtoken::errors::Error> {
|
||||||
|
let token_data = decode::<Claims>(
|
||||||
|
token,
|
||||||
|
&DecodingKey::from_secret(secret.as_bytes()),
|
||||||
|
&Validation::default(),
|
||||||
|
)?;
|
||||||
|
Ok(token_data.claims)
|
||||||
|
}
|
||||||
1
server/src/middleware/mod.rs
Normal file
1
server/src/middleware/mod.rs
Normal file
@ -0,0 +1 @@
|
|||||||
|
pub mod auth;
|
||||||
230
server/src/models/mod.rs
Normal file
230
server/src/models/mod.rs
Normal file
@ -0,0 +1,230 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
// ── Database models ──
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||||
|
pub struct User {
|
||||||
|
pub id: String,
|
||||||
|
pub email: String,
|
||||||
|
pub display_name: String,
|
||||||
|
pub password_hash: String,
|
||||||
|
pub created_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||||
|
pub struct Room {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub model_id: String,
|
||||||
|
pub created_by: String,
|
||||||
|
pub ai_always_respond: bool,
|
||||||
|
pub system_prompt: String,
|
||||||
|
pub created_at: String,
|
||||||
|
pub deleted_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||||
|
pub struct Message {
|
||||||
|
pub id: String,
|
||||||
|
pub room_id: String,
|
||||||
|
pub sender_id: String,
|
||||||
|
pub sender_name: String,
|
||||||
|
pub content: String,
|
||||||
|
pub mentions: String,
|
||||||
|
pub is_ai: bool,
|
||||||
|
pub created_at: String,
|
||||||
|
pub ai_meta: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||||
|
pub struct Invite {
|
||||||
|
pub id: String,
|
||||||
|
pub room_id: String,
|
||||||
|
pub invited_by: String,
|
||||||
|
pub email: String,
|
||||||
|
pub token: String,
|
||||||
|
pub used: bool,
|
||||||
|
pub created_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── API request/response types ──
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct RegisterRequest {
|
||||||
|
pub email: String,
|
||||||
|
pub password: String,
|
||||||
|
pub display_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct LoginRequest {
|
||||||
|
pub email: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct AuthResponse {
|
||||||
|
pub token: String,
|
||||||
|
pub user: UserPublic,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct UserPublic {
|
||||||
|
pub id: String,
|
||||||
|
pub email: String,
|
||||||
|
pub display_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct CreateRoomRequest {
|
||||||
|
pub name: String,
|
||||||
|
pub model_id: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub ai_always_respond: bool,
|
||||||
|
#[serde(default = "default_system_prompt")]
|
||||||
|
pub 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- **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()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct RoomResponse {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub model_id: String,
|
||||||
|
pub created_by: String,
|
||||||
|
pub ai_always_respond: bool,
|
||||||
|
pub system_prompt: String,
|
||||||
|
pub created_at: String,
|
||||||
|
pub members: Vec<UserPublic>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct CreateInviteRequest {
|
||||||
|
pub room_id: String,
|
||||||
|
pub email: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── WebSocket event types ──
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "type")]
|
||||||
|
pub enum WsClientMessage {
|
||||||
|
#[serde(rename = "send_message")]
|
||||||
|
SendMessage {
|
||||||
|
room_id: String,
|
||||||
|
content: String,
|
||||||
|
#[serde(default)]
|
||||||
|
mentions: Vec<String>,
|
||||||
|
},
|
||||||
|
#[serde(rename = "join_room")]
|
||||||
|
JoinRoom { room_id: String },
|
||||||
|
#[serde(rename = "typing")]
|
||||||
|
Typing { room_id: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "type")]
|
||||||
|
pub enum WsServerMessage {
|
||||||
|
#[serde(rename = "new_message")]
|
||||||
|
NewMessage {
|
||||||
|
message: MessagePayload,
|
||||||
|
},
|
||||||
|
#[serde(rename = "ai_typing")]
|
||||||
|
AiTyping {
|
||||||
|
room_id: String,
|
||||||
|
},
|
||||||
|
#[serde(rename = "user_typing")]
|
||||||
|
UserTyping {
|
||||||
|
room_id: String,
|
||||||
|
user_id: String,
|
||||||
|
display_name: String,
|
||||||
|
},
|
||||||
|
#[serde(rename = "error")]
|
||||||
|
Error {
|
||||||
|
message: String,
|
||||||
|
},
|
||||||
|
#[serde(rename = "joined")]
|
||||||
|
Joined {
|
||||||
|
room_id: String,
|
||||||
|
},
|
||||||
|
#[serde(rename = "room_deleted")]
|
||||||
|
RoomDeleted {
|
||||||
|
room_id: String,
|
||||||
|
},
|
||||||
|
#[serde(rename = "room_cleared")]
|
||||||
|
RoomCleared {
|
||||||
|
room_id: String,
|
||||||
|
},
|
||||||
|
#[serde(rename = "ai_tool_usage")]
|
||||||
|
AiToolUsage {
|
||||||
|
room_id: String,
|
||||||
|
tool_name: String,
|
||||||
|
status: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct MessagePayload {
|
||||||
|
pub id: String,
|
||||||
|
pub room_id: String,
|
||||||
|
pub sender_id: String,
|
||||||
|
pub sender_name: String,
|
||||||
|
pub content: String,
|
||||||
|
pub mentions: Vec<String>,
|
||||||
|
pub is_ai: bool,
|
||||||
|
pub created_at: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub ai_meta: Option<AiMeta>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct AiMeta {
|
||||||
|
pub model: String,
|
||||||
|
pub prompt_tokens: u32,
|
||||||
|
pub completion_tokens: u32,
|
||||||
|
pub total_tokens: u32,
|
||||||
|
pub response_ms: u64,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub tool_results: Option<Vec<ToolResult>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ToolResult {
|
||||||
|
pub tool: String,
|
||||||
|
pub input: String,
|
||||||
|
pub result: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Broadcast event (internal channel) ──
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct BroadcastEvent {
|
||||||
|
pub room_id: String,
|
||||||
|
pub message: WsServerMessage,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── JWT Claims ──
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct Claims {
|
||||||
|
pub sub: String, // user_id
|
||||||
|
pub email: String,
|
||||||
|
pub display_name: String,
|
||||||
|
pub exp: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Pagination ──
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct PaginationParams {
|
||||||
|
#[serde(default = "default_limit")]
|
||||||
|
pub limit: i64,
|
||||||
|
pub before: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_limit() -> i64 {
|
||||||
|
50
|
||||||
|
}
|
||||||
111
server/src/services/brave.rs
Normal file
111
server/src/services/brave.rs
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
const BRAVE_SEARCH_URL: &str = "https://api.search.brave.com/res/v1/web/search";
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct BraveResponse {
|
||||||
|
web: Option<BraveWebResults>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct BraveWebResults {
|
||||||
|
results: Vec<BraveResult>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct BraveResult {
|
||||||
|
title: String,
|
||||||
|
url: String,
|
||||||
|
#[serde(default)]
|
||||||
|
description: String,
|
||||||
|
age: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
extra_snippets: Option<Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A simplified search result for consumption by the AI.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct SearchResult {
|
||||||
|
pub title: String,
|
||||||
|
pub url: String,
|
||||||
|
pub description: String,
|
||||||
|
pub age: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Search the web using the Brave Search API.
|
||||||
|
/// Returns a list of simplified search results.
|
||||||
|
pub async fn search(
|
||||||
|
query: &str,
|
||||||
|
api_key: &str,
|
||||||
|
count: u8,
|
||||||
|
) -> Result<Vec<SearchResult>, String> {
|
||||||
|
let count = count.clamp(1, 10);
|
||||||
|
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
|
||||||
|
let response = client
|
||||||
|
.get(BRAVE_SEARCH_URL)
|
||||||
|
.header("X-Subscription-Token", api_key)
|
||||||
|
.header("Accept", "application/json")
|
||||||
|
.header("Accept-Encoding", "gzip")
|
||||||
|
.query(&[("q", query), ("count", &count.to_string())])
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Brave search request failed: {}", e))?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
let status = response.status();
|
||||||
|
let body = response.text().await.unwrap_or_default();
|
||||||
|
return Err(format!("Brave search error {}: {}", status, body));
|
||||||
|
}
|
||||||
|
|
||||||
|
let brave_response: BraveResponse = response
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to parse Brave response: {}", e))?;
|
||||||
|
|
||||||
|
let results = match brave_response.web {
|
||||||
|
Some(web) => web
|
||||||
|
.results
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| {
|
||||||
|
// Combine description with extra snippets for richer context
|
||||||
|
let mut desc = r.description;
|
||||||
|
if let Some(snippets) = r.extra_snippets {
|
||||||
|
for snippet in snippets.iter().take(2) {
|
||||||
|
desc.push_str(" ");
|
||||||
|
desc.push_str(snippet);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SearchResult {
|
||||||
|
title: r.title,
|
||||||
|
url: r.url,
|
||||||
|
description: desc,
|
||||||
|
age: r.age,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
None => vec![],
|
||||||
|
};
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
355
server/src/services/fetch.rs
Normal file
355
server/src/services/fetch.rs
Normal file
@ -0,0 +1,355 @@
|
|||||||
|
use scraper::{Html, Selector};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
/// Result of fetching and extracting content from a URL.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct FetchResult {
|
||||||
|
pub url: String,
|
||||||
|
pub title: Option<String>,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub content: String,
|
||||||
|
pub content_length: usize,
|
||||||
|
pub is_truncated: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tags to skip entirely during text extraction (noise elements).
|
||||||
|
const STRIP_TAGS: &[&str] = &[
|
||||||
|
"script", "style", "noscript", "svg", "iframe", "nav", "footer", "aside",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Block-level tags that should produce newlines in text output.
|
||||||
|
const BLOCK_TAGS: &[&str] = &[
|
||||||
|
"p", "div", "h1", "h2", "h3", "h4", "h5", "h6", "li", "br", "tr",
|
||||||
|
"blockquote", "pre", "section", "article", "main", "header",
|
||||||
|
"dt", "dd", "figcaption", "table", "thead", "tbody",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Fetch a URL and extract its text content.
|
||||||
|
/// Returns structured result with metadata and cleaned content.
|
||||||
|
pub async fn fetch_url(url: &str, max_chars: usize) -> Result<FetchResult, String> {
|
||||||
|
// Validate URL
|
||||||
|
if !url.starts_with("http://") && !url.starts_with("https://") {
|
||||||
|
return Err("URL must start with http:// or https://".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(Duration::from_secs(30))
|
||||||
|
.connect_timeout(Duration::from_secs(10))
|
||||||
|
.redirect(reqwest::redirect::Policy::limited(10))
|
||||||
|
.user_agent("GroupChat-AI/1.0 (Web Fetch Tool)")
|
||||||
|
.gzip(true)
|
||||||
|
.brotli(true)
|
||||||
|
.deflate(true)
|
||||||
|
.build()
|
||||||
|
.map_err(|e| format!("Failed to build HTTP client: {}", e))?;
|
||||||
|
|
||||||
|
let response = client
|
||||||
|
.get(url)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to fetch URL: {}", e))?;
|
||||||
|
|
||||||
|
let final_url = response.url().to_string();
|
||||||
|
let status = response.status();
|
||||||
|
|
||||||
|
if !status.is_success() {
|
||||||
|
return Err(format!(
|
||||||
|
"HTTP error {}: {}",
|
||||||
|
status.as_u16(),
|
||||||
|
status.canonical_reason().unwrap_or("Unknown")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check content type
|
||||||
|
let content_type = response
|
||||||
|
.headers()
|
||||||
|
.get("content-type")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.unwrap_or("text/html")
|
||||||
|
.to_lowercase();
|
||||||
|
|
||||||
|
if !is_text_content(&content_type) {
|
||||||
|
return Err(format!(
|
||||||
|
"Cannot read non-text content type: {}",
|
||||||
|
content_type
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check content length (reject > 2MB)
|
||||||
|
if let Some(len) = response.content_length() {
|
||||||
|
if len > 2 * 1024 * 1024 {
|
||||||
|
return Err(format!("Content too large: {} bytes (max 2MB)", len));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let body = response
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to read response body: {}", e))?;
|
||||||
|
|
||||||
|
// If it's plain text or JSON, return as-is (with truncation)
|
||||||
|
if content_type.contains("json") || content_type.starts_with("text/plain") {
|
||||||
|
let content_length = body.len();
|
||||||
|
let is_truncated = content_length > max_chars;
|
||||||
|
let content = if is_truncated {
|
||||||
|
truncate_at_boundary(&body, max_chars)
|
||||||
|
} else {
|
||||||
|
body
|
||||||
|
};
|
||||||
|
|
||||||
|
return Ok(FetchResult {
|
||||||
|
url: final_url,
|
||||||
|
title: None,
|
||||||
|
description: None,
|
||||||
|
content,
|
||||||
|
content_length,
|
||||||
|
is_truncated,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse HTML
|
||||||
|
let document = Html::parse_document(&body);
|
||||||
|
|
||||||
|
// Extract metadata
|
||||||
|
let title = extract_title(&document);
|
||||||
|
let description = extract_description(&document);
|
||||||
|
|
||||||
|
// Extract text content
|
||||||
|
let text = extract_text(&document);
|
||||||
|
let content_length = text.len();
|
||||||
|
let is_truncated = content_length > max_chars;
|
||||||
|
let content = if is_truncated {
|
||||||
|
truncate_at_boundary(&text, max_chars)
|
||||||
|
} else {
|
||||||
|
text
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(FetchResult {
|
||||||
|
url: final_url,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
content,
|
||||||
|
content_length,
|
||||||
|
is_truncated,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_text_content(ct: &str) -> bool {
|
||||||
|
ct.starts_with("text/")
|
||||||
|
|| ct.contains("json")
|
||||||
|
|| ct.contains("xml")
|
||||||
|
|| ct.contains("javascript")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_title(doc: &Html) -> Option<String> {
|
||||||
|
// Try og:title first
|
||||||
|
if let Ok(og_sel) = Selector::parse(r#"meta[property="og:title"]"#) {
|
||||||
|
if let Some(el) = doc.select(&og_sel).next() {
|
||||||
|
if let Some(content) = el.value().attr("content") {
|
||||||
|
let t = content.trim();
|
||||||
|
if !t.is_empty() {
|
||||||
|
return Some(t.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to <title>
|
||||||
|
if let Ok(title_sel) = Selector::parse("title") {
|
||||||
|
if let Some(el) = doc.select(&title_sel).next() {
|
||||||
|
let t: String = el.text().collect::<String>().trim().to_string();
|
||||||
|
if !t.is_empty() {
|
||||||
|
return Some(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_description(doc: &Html) -> Option<String> {
|
||||||
|
// Try og:description
|
||||||
|
if let Ok(og_sel) = Selector::parse(r#"meta[property="og:description"]"#) {
|
||||||
|
if let Some(el) = doc.select(&og_sel).next() {
|
||||||
|
if let Some(content) = el.value().attr("content") {
|
||||||
|
let d = content.trim();
|
||||||
|
if !d.is_empty() {
|
||||||
|
return Some(d.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to meta description
|
||||||
|
if let Ok(meta_sel) = Selector::parse(r#"meta[name="description"]"#) {
|
||||||
|
if let Some(el) = doc.select(&meta_sel).next() {
|
||||||
|
if let Some(content) = el.value().attr("content") {
|
||||||
|
let d = content.trim();
|
||||||
|
if !d.is_empty() {
|
||||||
|
return Some(d.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract text content from HTML, stripping noise elements.
|
||||||
|
fn extract_text(doc: &Html) -> String {
|
||||||
|
let mut output = String::new();
|
||||||
|
|
||||||
|
// Try to find main content area first
|
||||||
|
let main_selectors = ["main", "article", "[role=\"main\"]", "#content", ".content"];
|
||||||
|
let mut root = None;
|
||||||
|
for sel_str in &main_selectors {
|
||||||
|
if let Ok(sel) = Selector::parse(sel_str) {
|
||||||
|
if let Some(el) = doc.select(&sel).next() {
|
||||||
|
root = Some(el);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to body
|
||||||
|
if root.is_none() {
|
||||||
|
if let Ok(body_sel) = Selector::parse("body") {
|
||||||
|
root = doc.select(&body_sel).next();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(root_el) = root {
|
||||||
|
extract_node_text(&root_el, &mut output);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up: collapse excessive whitespace/newlines
|
||||||
|
let mut cleaned = String::new();
|
||||||
|
let mut blank_count = 0;
|
||||||
|
for line in output.lines() {
|
||||||
|
let trimmed = line.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
blank_count += 1;
|
||||||
|
if blank_count <= 2 {
|
||||||
|
cleaned.push('\n');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
blank_count = 0;
|
||||||
|
cleaned.push_str(trimmed);
|
||||||
|
cleaned.push('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cleaned.trim().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recursively extract text from a node, skipping noise elements.
|
||||||
|
fn extract_node_text(element: &scraper::ElementRef, output: &mut String) {
|
||||||
|
for child in element.children() {
|
||||||
|
match child.value() {
|
||||||
|
scraper::node::Node::Text(text) => {
|
||||||
|
let t = text.trim();
|
||||||
|
if !t.is_empty() {
|
||||||
|
output.push_str(t);
|
||||||
|
output.push(' ');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
scraper::node::Node::Element(el) => {
|
||||||
|
let tag = el.name();
|
||||||
|
|
||||||
|
// Skip noise elements entirely
|
||||||
|
if STRIP_TAGS.contains(&tag) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let child_ref = scraper::ElementRef::wrap(child);
|
||||||
|
if let Some(child_el) = child_ref {
|
||||||
|
// Add newline before block elements
|
||||||
|
let is_block = BLOCK_TAGS.contains(&tag);
|
||||||
|
if is_block {
|
||||||
|
output.push('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle headings specially — add markdown-style prefix
|
||||||
|
match tag {
|
||||||
|
"h1" => output.push_str("# "),
|
||||||
|
"h2" => output.push_str("## "),
|
||||||
|
"h3" => output.push_str("### "),
|
||||||
|
"li" => output.push_str("- "),
|
||||||
|
"a" => {
|
||||||
|
// Extract link text and URL
|
||||||
|
let text: String = child_el.text().collect();
|
||||||
|
let href = el.attr("href").unwrap_or("");
|
||||||
|
if !text.trim().is_empty()
|
||||||
|
&& !href.is_empty()
|
||||||
|
&& href.starts_with("http")
|
||||||
|
{
|
||||||
|
output.push_str(&format!("[{}]({})", text.trim(), href));
|
||||||
|
output.push(' ');
|
||||||
|
continue; // Don't recurse into <a> children
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
extract_node_text(&child_el, output);
|
||||||
|
|
||||||
|
if is_block {
|
||||||
|
output.push('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Truncate content at a paragraph or line boundary.
|
||||||
|
fn truncate_at_boundary(text: &str, max_chars: usize) -> String {
|
||||||
|
if text.len() <= max_chars {
|
||||||
|
return text.to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
let slice = &text[..max_chars];
|
||||||
|
|
||||||
|
// Try to break at a double newline (paragraph boundary)
|
||||||
|
if let Some(pos) = slice.rfind("\n\n") {
|
||||||
|
return slice[..pos].to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to break at a single newline
|
||||||
|
if let Some(pos) = slice.rfind('\n') {
|
||||||
|
return slice[..pos].to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try word boundary
|
||||||
|
if let Some(pos) = slice.rfind(' ') {
|
||||||
|
return slice[..pos].to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last resort: hard cut
|
||||||
|
slice.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format a fetch result into a readable string for the AI.
|
||||||
|
pub fn format_result(result: &FetchResult) -> String {
|
||||||
|
let mut output = String::new();
|
||||||
|
|
||||||
|
output.push_str(&format!("URL: {}\n", result.url));
|
||||||
|
if let Some(title) = &result.title {
|
||||||
|
output.push_str(&format!("Title: {}\n", title));
|
||||||
|
}
|
||||||
|
if let Some(desc) = &result.description {
|
||||||
|
output.push_str(&format!("Description: {}\n", desc));
|
||||||
|
}
|
||||||
|
output.push_str(&format!(
|
||||||
|
"Content length: {} characters",
|
||||||
|
result.content_length
|
||||||
|
));
|
||||||
|
if result.is_truncated {
|
||||||
|
output.push_str(" (truncated)");
|
||||||
|
}
|
||||||
|
output.push_str("\n\n");
|
||||||
|
output.push_str(&result.content);
|
||||||
|
|
||||||
|
output
|
||||||
|
}
|
||||||
3
server/src/services/mod.rs
Normal file
3
server/src/services/mod.rs
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
pub mod brave;
|
||||||
|
pub mod fetch;
|
||||||
|
pub mod openrouter;
|
||||||
258
server/src/services/openrouter.rs
Normal file
258
server/src/services/openrouter.rs
Normal file
@ -0,0 +1,258 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
const OPENROUTER_API_URL: &str = "https://openrouter.ai/api/v1/chat/completions";
|
||||||
|
|
||||||
|
// ── Request types ──
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct ChatRequest {
|
||||||
|
model: String,
|
||||||
|
messages: Vec<ChatMessage>,
|
||||||
|
max_tokens: Option<u32>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
tools: Option<Vec<Tool>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
pub struct ChatMessage {
|
||||||
|
pub role: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub content: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub tool_calls: Option<Vec<ToolCall>>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub tool_call_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tool definition types ──
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
pub struct Tool {
|
||||||
|
pub r#type: String,
|
||||||
|
pub function: ToolFunction,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
pub struct ToolFunction {
|
||||||
|
pub name: String,
|
||||||
|
pub description: String,
|
||||||
|
pub parameters: serde_json::Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
pub struct ToolCall {
|
||||||
|
pub id: String,
|
||||||
|
pub r#type: String,
|
||||||
|
pub function: ToolCallFunction,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
pub struct ToolCallFunction {
|
||||||
|
pub name: String,
|
||||||
|
pub arguments: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Response types ──
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct ChatResponse {
|
||||||
|
choices: Vec<Choice>,
|
||||||
|
model: Option<String>,
|
||||||
|
usage: Option<Usage>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct Choice {
|
||||||
|
message: ChoiceMessage,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct ChoiceMessage {
|
||||||
|
content: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
tool_calls: Option<Vec<ToolCall>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct Usage {
|
||||||
|
prompt_tokens: Option<u32>,
|
||||||
|
completion_tokens: Option<u32>,
|
||||||
|
total_tokens: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stats returned alongside an AI completion.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct CompletionStats {
|
||||||
|
pub model: String,
|
||||||
|
pub prompt_tokens: u32,
|
||||||
|
pub completion_tokens: u32,
|
||||||
|
pub total_tokens: u32,
|
||||||
|
pub response_ms: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result from a chat completion — either a final text response or tool calls.
|
||||||
|
pub enum ChatCompletionResult {
|
||||||
|
/// AI responded with text content.
|
||||||
|
Response(String, CompletionStats),
|
||||||
|
/// AI wants to call tools. Contains the assistant message (with tool_calls) and stats.
|
||||||
|
ToolCalls(ChatMessage, CompletionStats),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the tool definitions for brave_search and web_fetch.
|
||||||
|
pub fn build_tools() -> Vec<Tool> {
|
||||||
|
vec![
|
||||||
|
Tool {
|
||||||
|
r#type: "function".into(),
|
||||||
|
function: ToolFunction {
|
||||||
|
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(),
|
||||||
|
parameters: serde_json::json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"query": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The search query"
|
||||||
|
},
|
||||||
|
"count": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Number of results (1-10, default 5)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["query"]
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Tool {
|
||||||
|
r#type: "function".into(),
|
||||||
|
function: ToolFunction {
|
||||||
|
name: "web_fetch".into(),
|
||||||
|
description: "Fetch and read the content of a web page. Use this to read articles, documentation, or any URL shared by users or found in search results.".into(),
|
||||||
|
parameters: serde_json::json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"url": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The URL to fetch"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["url"]
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a chat completion request to OpenRouter.
|
||||||
|
/// Returns either a text response or tool call requests.
|
||||||
|
pub async fn chat_completion(
|
||||||
|
history: Vec<ChatMessage>,
|
||||||
|
model_id: &str,
|
||||||
|
api_key: &str,
|
||||||
|
tools: Option<Vec<Tool>>,
|
||||||
|
) -> Result<ChatCompletionResult, String> {
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
|
||||||
|
let request_body = ChatRequest {
|
||||||
|
model: model_id.to_string(),
|
||||||
|
messages: history,
|
||||||
|
max_tokens: Some(2048),
|
||||||
|
tools,
|
||||||
|
};
|
||||||
|
|
||||||
|
let start = std::time::Instant::now();
|
||||||
|
|
||||||
|
let response = client
|
||||||
|
.post(OPENROUTER_API_URL)
|
||||||
|
.header("Authorization", format!("Bearer {}", api_key))
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.header("HTTP-Referer", "http://localhost:3001")
|
||||||
|
.header("X-Title", "GroupChat")
|
||||||
|
.json(&request_body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("OpenRouter request failed: {}", e))?;
|
||||||
|
|
||||||
|
let elapsed_ms = start.elapsed().as_millis() as u64;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
let status = response.status();
|
||||||
|
let body = response.text().await.unwrap_or_default();
|
||||||
|
return Err(format!("OpenRouter error {}: {}", status, body));
|
||||||
|
}
|
||||||
|
|
||||||
|
let chat_response: ChatResponse = response
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to parse OpenRouter response: {}", e))?;
|
||||||
|
|
||||||
|
let choice = chat_response
|
||||||
|
.choices
|
||||||
|
.first()
|
||||||
|
.ok_or_else(|| "No response from OpenRouter".to_string())?;
|
||||||
|
|
||||||
|
let usage = chat_response.usage.unwrap_or(Usage {
|
||||||
|
prompt_tokens: None,
|
||||||
|
completion_tokens: None,
|
||||||
|
total_tokens: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
let stats = CompletionStats {
|
||||||
|
model: chat_response.model.unwrap_or_else(|| model_id.to_string()),
|
||||||
|
prompt_tokens: usage.prompt_tokens.unwrap_or(0),
|
||||||
|
completion_tokens: usage.completion_tokens.unwrap_or(0),
|
||||||
|
total_tokens: usage.total_tokens.unwrap_or(0),
|
||||||
|
response_ms: elapsed_ms,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if the AI wants to call tools
|
||||||
|
if let Some(tool_calls) = &choice.message.tool_calls {
|
||||||
|
if !tool_calls.is_empty() {
|
||||||
|
// Return the assistant message with tool calls so it can be added to history
|
||||||
|
let assistant_msg = ChatMessage {
|
||||||
|
role: "assistant".into(),
|
||||||
|
content: choice.message.content.clone(),
|
||||||
|
tool_calls: Some(tool_calls.clone()),
|
||||||
|
tool_call_id: None,
|
||||||
|
};
|
||||||
|
return Ok(ChatCompletionResult::ToolCalls(assistant_msg, stats));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regular text response
|
||||||
|
let content = choice.message.content.clone().unwrap_or_default();
|
||||||
|
Ok(ChatCompletionResult::Response(content, stats))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the message history for OpenRouter from stored messages.
|
||||||
|
/// Includes the system prompt as the first message.
|
||||||
|
pub fn build_chat_history(
|
||||||
|
system_prompt: &str,
|
||||||
|
messages: &[(String, String, bool)], // (sender_name, content, is_ai)
|
||||||
|
) -> Vec<ChatMessage> {
|
||||||
|
let mut history = vec![ChatMessage {
|
||||||
|
role: "system".to_string(),
|
||||||
|
content: Some(system_prompt.to_string()),
|
||||||
|
tool_calls: None,
|
||||||
|
tool_call_id: None,
|
||||||
|
}];
|
||||||
|
|
||||||
|
for (sender_name, content, is_ai) in messages {
|
||||||
|
if *is_ai {
|
||||||
|
history.push(ChatMessage {
|
||||||
|
role: "assistant".to_string(),
|
||||||
|
content: Some(content.clone()),
|
||||||
|
tool_calls: None,
|
||||||
|
tool_call_id: None,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
history.push(ChatMessage {
|
||||||
|
role: "user".to_string(),
|
||||||
|
content: Some(format!("[{}]: {}", sender_name, content)),
|
||||||
|
tool_calls: None,
|
||||||
|
tool_call_id: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
history
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user