feat: add room member list, AI agent naming, and fix invite flow
- Add clickable member count in room header that shows a dropdown with all room members (AI agent + humans) with role badges - Give each room's AI agent a random name from 10 options (Nova, Atlas, Sage, etc.), editable when creating the room - Replace AI text avatar with a robot SVG icon across all components - Fix broken invite system: add client-side URL routing for /invite/:token, handle post-login invite acceptance via sessionStorage, and return room_id from accept_invite endpoint for auto-navigation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
4a002c85d4
commit
df59accb81
@ -168,6 +168,9 @@
|
||||
if (user && isAuthenticated()) {
|
||||
this.update({ user })
|
||||
this.initChat()
|
||||
} else {
|
||||
// Not logged in — store invite token so we can accept after login
|
||||
this.checkPendingInvite()
|
||||
}
|
||||
},
|
||||
|
||||
@ -216,7 +219,7 @@
|
||||
id: this._streamMsgId,
|
||||
room_id: msg.room_id,
|
||||
sender_id: 'ai-assistant',
|
||||
sender_name: 'AI Assistant',
|
||||
sender_name: this.state.activeRoom?.ai_name || 'AI Assistant',
|
||||
content: this._streamContent,
|
||||
mentions: [],
|
||||
is_ai: true,
|
||||
@ -301,6 +304,9 @@
|
||||
} catch (e) {
|
||||
console.error('Failed to load rooms:', e)
|
||||
}
|
||||
|
||||
// Process any pending invite token
|
||||
await this.processInviteToken()
|
||||
},
|
||||
|
||||
handleLogin(data) {
|
||||
@ -381,6 +387,45 @@
|
||||
this.update({ messages: [], showClearModal: false })
|
||||
},
|
||||
|
||||
/** Check URL for /invite/:token and stash it for after login if needed */
|
||||
checkPendingInvite() {
|
||||
const match = window.location.pathname.match(/^\/invite\/(.+)$/)
|
||||
if (match) {
|
||||
sessionStorage.setItem('pendingInvite', match[1])
|
||||
// Clean URL so it doesn't confuse things
|
||||
window.history.replaceState(null, '', '/')
|
||||
}
|
||||
},
|
||||
|
||||
/** Accept an invite token — called after login + rooms load */
|
||||
async processInviteToken() {
|
||||
// Check URL first, then sessionStorage (for post-login flow)
|
||||
let token = null
|
||||
const urlMatch = window.location.pathname.match(/^\/invite\/(.+)$/)
|
||||
if (urlMatch) {
|
||||
token = urlMatch[1]
|
||||
window.history.replaceState(null, '', '/')
|
||||
} else {
|
||||
token = sessionStorage.getItem('pendingInvite')
|
||||
}
|
||||
|
||||
if (!token) return
|
||||
sessionStorage.removeItem('pendingInvite')
|
||||
|
||||
try {
|
||||
const result = await api.acceptInvite(token)
|
||||
// Refresh room list — the accepted room should now appear
|
||||
const rooms = await api.listRooms()
|
||||
this.update({ rooms })
|
||||
// Auto-select the room the user just joined
|
||||
if (result?.room_id) {
|
||||
this.selectRoom(result.room_id)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to accept invite:', e)
|
||||
}
|
||||
},
|
||||
|
||||
scrollToBottom() {
|
||||
requestAnimationFrame(() => {
|
||||
const container = document.querySelector('.messages-list')
|
||||
|
||||
@ -4,9 +4,18 @@
|
||||
<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>
|
||||
<button class="members-toggle" onclick={toggleMembers}>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
|
||||
<circle cx="9" cy="7" r="4"/>
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
|
||||
</svg>
|
||||
{(props.room?.members?.length || 0) + 1} members
|
||||
<svg class={'members-chevron ' + (state.showMembers ? 'open' : '')} width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="6 9 12 15 18 9"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="room-actions">
|
||||
<button class="btn btn-ghost btn-icon" onclick={props.cbInvite} title="Invite">
|
||||
@ -28,6 +37,28 @@
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Member list dropdown -->
|
||||
<div if={state.showMembers} class="members-dropdown">
|
||||
<div class="member-item ai-member">
|
||||
<div class="member-avatar ai-avatar">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="4" y="8" width="16" height="12" rx="2"/>
|
||||
<line x1="12" y1="2" x2="12" y2="8"/>
|
||||
<circle cx="12" cy="2" r="1" fill="currentColor"/>
|
||||
<circle cx="9" cy="14" r="1.5" fill="currentColor"/>
|
||||
<circle cx="15" cy="14" r="1.5" fill="currentColor"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="member-name">{props.room?.ai_name || 'AI Assistant'}</span>
|
||||
<span class="member-role ai-role">AI</span>
|
||||
</div>
|
||||
<div each={member in props.room?.members} key={member.id} class="member-item">
|
||||
<div class="member-avatar">{member.display_name?.charAt(0).toUpperCase()}</div>
|
||||
<span class="member-name">{member.display_name}</span>
|
||||
<span if={member.id === props.room?.created_by} class="member-role">Owner</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Messages -->
|
||||
@ -51,7 +82,15 @@
|
||||
|
||||
<!-- AI typing indicator (only when NOT streaming content) -->
|
||||
<div if={props.aiTyping && !props.streamingMessage} class="typing-indicator ai-typing">
|
||||
<div class="typing-avatar ai-avatar">AI</div>
|
||||
<div class="typing-avatar ai-avatar">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="4" y="8" width="16" height="12" rx="2"/>
|
||||
<line x1="12" y1="2" x2="12" y2="8"/>
|
||||
<circle cx="12" cy="2" r="1" fill="currentColor"/>
|
||||
<circle cx="9" cy="14" r="1.5" fill="currentColor"/>
|
||||
<circle cx="15" cy="14" r="1.5" fill="currentColor"/>
|
||||
</svg>
|
||||
</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...'}
|
||||
@ -75,7 +114,7 @@
|
||||
<textarea
|
||||
ref="input"
|
||||
class="message-input"
|
||||
placeholder="Type a message... (use @ai to mention the AI)"
|
||||
placeholder={'Type a message... (use @ai or @' + (props.room?.ai_name || 'AI') + ')'}
|
||||
rows="1"
|
||||
oninput={handleInput}
|
||||
onkeydown={handleKeydown}
|
||||
@ -109,6 +148,7 @@
|
||||
height: var(--header-height);
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg-secondary);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.room-header-info h3 {
|
||||
@ -116,9 +156,99 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.room-meta {
|
||||
font-size: var(--text-xs);
|
||||
.members-toggle {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--text-xs);
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.members-toggle:hover {
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.members-chevron {
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
|
||||
.members-chevron.open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.members-dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: var(--space-lg);
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-xs) 0;
|
||||
min-width: 200px;
|
||||
max-width: 280px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||
z-index: 20;
|
||||
animation: fadeIn 0.15s ease;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.member-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-xs) var(--space-md);
|
||||
}
|
||||
|
||||
.member-avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
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);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.member-item .ai-avatar {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.member-name {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-primary);
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.member-role {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-elevated);
|
||||
padding: 1px 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.ai-role {
|
||||
color: var(--accent);
|
||||
background: rgba(108, 92, 231, 0.15);
|
||||
}
|
||||
|
||||
.room-actions {
|
||||
@ -288,6 +418,25 @@
|
||||
state: {
|
||||
inputValue: '',
|
||||
typingDisplay: '',
|
||||
showMembers: false,
|
||||
},
|
||||
|
||||
onMounted() {
|
||||
this._closeMembers = (e) => {
|
||||
if (this.state.showMembers && !this.$('.members-toggle')?.contains(e.target) && !this.$('.members-dropdown')?.contains(e.target)) {
|
||||
this.update({ showMembers: false })
|
||||
}
|
||||
}
|
||||
document.addEventListener('click', this._closeMembers)
|
||||
},
|
||||
|
||||
onUnmounted() {
|
||||
document.removeEventListener('click', this._closeMembers)
|
||||
},
|
||||
|
||||
toggleMembers(e) {
|
||||
e.stopPropagation()
|
||||
this.update({ showMembers: !this.state.showMembers })
|
||||
},
|
||||
|
||||
onUpdated() {
|
||||
@ -343,9 +492,11 @@
|
||||
return
|
||||
}
|
||||
|
||||
// Extract mentions (simple @ai detection)
|
||||
// Extract mentions (@ai or @{aiName} detection)
|
||||
const mentions = []
|
||||
if (content.toLowerCase().includes('@ai')) {
|
||||
const lc = content.toLowerCase()
|
||||
const aiName = this.props.room?.ai_name?.toLowerCase() || ''
|
||||
if (lc.includes('@ai') || (aiName && lc.includes(`@${aiName}`))) {
|
||||
mentions.push('ai-assistant')
|
||||
}
|
||||
|
||||
|
||||
@ -95,6 +95,19 @@
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="ai-name">AI Agent Name</label>
|
||||
<input
|
||||
type="text"
|
||||
id="ai-name"
|
||||
placeholder="e.g., Nova, Atlas, Sage..."
|
||||
value={state.ai_name}
|
||||
oninput={e => update({ ai_name: e.target.value })}
|
||||
maxlength="20"
|
||||
/>
|
||||
<span class="help-text">The AI's display name in this room</span>
|
||||
</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>
|
||||
@ -343,6 +356,7 @@
|
||||
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.',
|
||||
ai_name: '',
|
||||
models: [],
|
||||
loadingModels: true,
|
||||
showPicker: false,
|
||||
@ -350,6 +364,8 @@
|
||||
},
|
||||
|
||||
onMounted() {
|
||||
const aiNames = ['Nova', 'Atlas', 'Sage', 'Echo', 'Pixel', 'Cosmo', 'Ember', 'Flux', 'Lyra', 'Onyx']
|
||||
this.update({ ai_name: aiNames[Math.floor(Math.random() * aiNames.length)] })
|
||||
this.fetchModels()
|
||||
// Close picker on outside click
|
||||
this._onDocClick = (e) => {
|
||||
@ -430,6 +446,7 @@
|
||||
model_id: this.state.model_id,
|
||||
ai_always_respond: this.state.ai_always_respond,
|
||||
system_prompt: this.state.system_prompt,
|
||||
ai_name: this.state.ai_name,
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
@ -2,7 +2,14 @@
|
||||
<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()}
|
||||
<svg if={props.message?.is_ai} width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="4" y="8" width="16" height="12" rx="2"/>
|
||||
<line x1="12" y1="2" x2="12" y2="8"/>
|
||||
<circle cx="12" cy="2" r="1" fill="currentColor"/>
|
||||
<circle cx="9" cy="14" r="1.5" fill="currentColor"/>
|
||||
<circle cx="15" cy="14" r="1.5" fill="currentColor"/>
|
||||
</svg>
|
||||
<template if={!props.message?.is_ai}>{props.message?.sender_name?.charAt(0).toUpperCase()}</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="message-body">
|
||||
|
||||
2
server/migrations/004_ai_name.sql
Normal file
2
server/migrations/004_ai_name.sql
Normal file
@ -0,0 +1,2 @@
|
||||
-- Add AI agent name to rooms
|
||||
ALTER TABLE rooms ADD COLUMN ai_name TEXT NOT NULL DEFAULT 'AI Assistant';
|
||||
@ -63,11 +63,16 @@ pub async fn create_invite(
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
pub struct AcceptInviteResponse {
|
||||
pub room_id: String,
|
||||
}
|
||||
|
||||
pub async fn accept_invite(
|
||||
State(state): State<Arc<AppState>>,
|
||||
auth: AuthUser,
|
||||
Path(token): Path<String>,
|
||||
) -> Result<StatusCode, (StatusCode, String)> {
|
||||
) -> Result<Json<AcceptInviteResponse>, (StatusCode, String)> {
|
||||
let invite = sqlx::query_as::<_, (String, String, bool)>(
|
||||
"SELECT id, room_id, used FROM invites WHERE token = ?",
|
||||
)
|
||||
@ -111,5 +116,5 @@ pub async fn accept_invite(
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(StatusCode::OK)
|
||||
Ok(Json(AcceptInviteResponse { room_id }))
|
||||
}
|
||||
|
||||
@ -20,7 +20,7 @@ pub async fn create_room(
|
||||
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 (?, ?, ?, ?, ?, ?)",
|
||||
"INSERT INTO rooms (id, name, model_id, created_by, ai_always_respond, system_prompt, ai_name) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(&room_id)
|
||||
.bind(&body.name)
|
||||
@ -28,6 +28,7 @@ pub async fn create_room(
|
||||
.bind(&auth.user_id)
|
||||
.bind(body.ai_always_respond)
|
||||
.bind(&body.system_prompt)
|
||||
.bind(&body.ai_name)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
@ -47,6 +48,7 @@ pub async fn create_room(
|
||||
created_by: auth.user_id.clone(),
|
||||
ai_always_respond: body.ai_always_respond,
|
||||
system_prompt: body.system_prompt,
|
||||
ai_name: body.ai_name,
|
||||
created_at: chrono::Utc::now().to_rfc3339(),
|
||||
members: vec![UserPublic {
|
||||
id: auth.user_id,
|
||||
@ -85,6 +87,7 @@ pub async fn list_rooms(
|
||||
created_by: room.created_by,
|
||||
ai_always_respond: room.ai_always_respond,
|
||||
system_prompt: room.system_prompt,
|
||||
ai_name: room.ai_name,
|
||||
created_at: room.created_at,
|
||||
members: members
|
||||
.into_iter()
|
||||
@ -141,6 +144,7 @@ pub async fn get_room(
|
||||
created_by: room.created_by,
|
||||
ai_always_respond: room.ai_always_respond,
|
||||
system_prompt: room.system_prompt,
|
||||
ai_name: room.ai_name,
|
||||
created_at: room.created_at,
|
||||
members: members
|
||||
.into_iter()
|
||||
|
||||
@ -197,14 +197,14 @@ async fn handle_send_message(
|
||||
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",
|
||||
let room = sqlx::query_as::<_, (String, bool, String, String)>(
|
||||
"SELECT model_id, ai_always_respond, system_prompt, ai_name 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 {
|
||||
let (model_id, always_respond, system_prompt, ai_name) = match room {
|
||||
Ok(Some(r)) => r,
|
||||
_ => return,
|
||||
};
|
||||
@ -390,7 +390,7 @@ async fn handle_send_message(
|
||||
.bind(&ai_msg_id)
|
||||
.bind(room_id)
|
||||
.bind(ai_user_id)
|
||||
.bind("AI Assistant")
|
||||
.bind(&ai_name)
|
||||
.bind(&ai_response)
|
||||
.bind(&ai_now)
|
||||
.bind(&ai_meta_json)
|
||||
@ -402,7 +402,7 @@ async fn handle_send_message(
|
||||
id: ai_msg_id,
|
||||
room_id: room_id.to_string(),
|
||||
sender_id: ai_user_id.to_string(),
|
||||
sender_name: "AI Assistant".to_string(),
|
||||
sender_name: ai_name.clone(),
|
||||
content: ai_response,
|
||||
mentions: vec![],
|
||||
is_ai: true,
|
||||
|
||||
@ -70,6 +70,16 @@ async fn main() {
|
||||
Err(e) => panic!("Failed to run migration 003: {}", e),
|
||||
}
|
||||
|
||||
// Run migration 004 - ai_name on rooms
|
||||
let migration_004 = include_str!("../migrations/004_ai_name.sql");
|
||||
match sqlx::raw_sql(migration_004).execute(&db).await {
|
||||
Ok(_) => tracing::info!("Migration 004 applied"),
|
||||
Err(e) if e.to_string().contains("duplicate column") => {
|
||||
tracing::debug!("Migration 004 already applied, skipping");
|
||||
}
|
||||
Err(e) => panic!("Failed to run migration 004: {}", e),
|
||||
}
|
||||
|
||||
tracing::info!("Database initialized");
|
||||
|
||||
let (tx, _rx) = broadcast::channel::<models::BroadcastEvent>(4096);
|
||||
|
||||
@ -19,6 +19,7 @@ pub struct Room {
|
||||
pub created_by: String,
|
||||
pub ai_always_respond: bool,
|
||||
pub system_prompt: String,
|
||||
pub ai_name: String,
|
||||
pub created_at: String,
|
||||
pub deleted_at: Option<String>,
|
||||
}
|
||||
@ -83,6 +84,20 @@ pub struct CreateRoomRequest {
|
||||
pub ai_always_respond: bool,
|
||||
#[serde(default = "default_system_prompt")]
|
||||
pub system_prompt: String,
|
||||
#[serde(default = "default_ai_name")]
|
||||
pub ai_name: String,
|
||||
}
|
||||
|
||||
fn default_ai_name() -> String {
|
||||
let names = [
|
||||
"Nova", "Atlas", "Sage", "Echo", "Pixel",
|
||||
"Cosmo", "Ember", "Flux", "Lyra", "Onyx",
|
||||
];
|
||||
let idx = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as usize % names.len())
|
||||
.unwrap_or(0);
|
||||
names[idx].to_string()
|
||||
}
|
||||
|
||||
fn default_system_prompt() -> String {
|
||||
@ -97,6 +112,7 @@ pub struct RoomResponse {
|
||||
pub created_by: String,
|
||||
pub ai_always_respond: bool,
|
||||
pub system_prompt: String,
|
||||
pub ai_name: String,
|
||||
pub created_at: String,
|
||||
pub members: Vec<UserPublic>,
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user