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:
Jason Tudisco 2026-03-06 18:50:52 -06:00
commit 01258fa958
43 changed files with 11511 additions and 0 deletions

View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

23
client/package.json Normal file
View 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"
}
}

View 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>

View 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()} &middot; {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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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
View 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'))

View 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()
}

View 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)
}

View 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()

View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

26
server/Cargo.toml Normal file
View 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"

View 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'))
);

View File

@ -0,0 +1,2 @@
-- Add soft-delete support for rooms
ALTER TABLE rooms ADD COLUMN deleted_at TEXT NULL;

View 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
View 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,
})
}

View 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)
}

View File

@ -0,0 +1,5 @@
pub mod auth;
pub mod invites;
pub mod models;
pub mod rooms;
pub mod ws;

View 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()))
}

View 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) = &params.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
View 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
View 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();
}

View 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)
}

View File

@ -0,0 +1 @@
pub mod auth;

230
server/src/models/mod.rs Normal file
View 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
}

View 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
}

View 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
}

View File

@ -0,0 +1,3 @@
pub mod brave;
pub mod fetch;
pub mod openrouter;

View 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
}