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