diff --git a/client/src/components/app.riot b/client/src/components/app.riot
index 8d94548..9fd1dee 100644
--- a/client/src/components/app.riot
+++ b/client/src/components/app.riot
@@ -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')
diff --git a/client/src/components/chat-room.riot b/client/src/components/chat-room.riot
index 0f78277..396751f 100644
--- a/client/src/components/chat-room.riot
+++ b/client/src/components/chat-room.riot
@@ -4,9 +4,18 @@
@@ -51,7 +82,15 @@
-
AI
+
+
+
{props.aiToolStatus.tool === 'brave_search' ? '🔍 Searching...' : props.aiToolStatus.tool === 'web_fetch' ? '🌐 Reading page...' : '⚙️ Using tool...'}
@@ -75,7 +114,7 @@
+
+
+ update({ ai_name: e.target.value })}
+ maxlength="20"
+ />
+ The AI's display name in this room
+
+
@@ -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,
})
},
}
diff --git a/client/src/components/message-bubble.riot b/client/src/components/message-bubble.riot
index 3df89e1..32c03e0 100644
--- a/client/src/components/message-bubble.riot
+++ b/client/src/components/message-bubble.riot
@@ -2,7 +2,14 @@
- {props.message?.is_ai ? 'AI' : props.message?.sender_name?.charAt(0).toUpperCase()}
+
+ {props.message?.sender_name?.charAt(0).toUpperCase()}
diff --git a/server/migrations/004_ai_name.sql b/server/migrations/004_ai_name.sql
new file mode 100644
index 0000000..99201d8
--- /dev/null
+++ b/server/migrations/004_ai_name.sql
@@ -0,0 +1,2 @@
+-- Add AI agent name to rooms
+ALTER TABLE rooms ADD COLUMN ai_name TEXT NOT NULL DEFAULT 'AI Assistant';
diff --git a/server/src/handlers/invites.rs b/server/src/handlers/invites.rs
index 252dabf..f7e24c9 100644
--- a/server/src/handlers/invites.rs
+++ b/server/src/handlers/invites.rs
@@ -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
>,
auth: AuthUser,
Path(token): Path,
-) -> Result {
+) -> Result, (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 }))
}
diff --git a/server/src/handlers/rooms.rs b/server/src/handlers/rooms.rs
index ced1d88..717d367 100644
--- a/server/src/handlers/rooms.rs
+++ b/server/src/handlers/rooms.rs
@@ -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()
diff --git a/server/src/handlers/ws.rs b/server/src/handlers/ws.rs
index 3fafe60..8d4e705 100644
--- a/server/src/handlers/ws.rs
+++ b/server/src/handlers/ws.rs
@@ -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,
diff --git a/server/src/main.rs b/server/src/main.rs
index 23d0c07..d9ac180 100644
--- a/server/src/main.rs
+++ b/server/src/main.rs
@@ -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::(4096);
diff --git a/server/src/models/mod.rs b/server/src/models/mod.rs
index 120d873..6a2c2fa 100644
--- a/server/src/models/mod.rs
+++ b/server/src/models/mod.rs
@@ -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,
}
@@ -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,
}