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()) {
|
if (user && isAuthenticated()) {
|
||||||
this.update({ user })
|
this.update({ user })
|
||||||
this.initChat()
|
this.initChat()
|
||||||
|
} else {
|
||||||
|
// Not logged in — store invite token so we can accept after login
|
||||||
|
this.checkPendingInvite()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -216,7 +219,7 @@
|
|||||||
id: this._streamMsgId,
|
id: this._streamMsgId,
|
||||||
room_id: msg.room_id,
|
room_id: msg.room_id,
|
||||||
sender_id: 'ai-assistant',
|
sender_id: 'ai-assistant',
|
||||||
sender_name: 'AI Assistant',
|
sender_name: this.state.activeRoom?.ai_name || 'AI Assistant',
|
||||||
content: this._streamContent,
|
content: this._streamContent,
|
||||||
mentions: [],
|
mentions: [],
|
||||||
is_ai: true,
|
is_ai: true,
|
||||||
@ -301,6 +304,9 @@
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to load rooms:', e)
|
console.error('Failed to load rooms:', e)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Process any pending invite token
|
||||||
|
await this.processInviteToken()
|
||||||
},
|
},
|
||||||
|
|
||||||
handleLogin(data) {
|
handleLogin(data) {
|
||||||
@ -381,6 +387,45 @@
|
|||||||
this.update({ messages: [], showClearModal: false })
|
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() {
|
scrollToBottom() {
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
const container = document.querySelector('.messages-list')
|
const container = document.querySelector('.messages-list')
|
||||||
|
|||||||
@ -4,9 +4,18 @@
|
|||||||
<header class="room-header">
|
<header class="room-header">
|
||||||
<div class="room-header-info">
|
<div class="room-header-info">
|
||||||
<h3>{props.room?.name}</h3>
|
<h3>{props.room?.name}</h3>
|
||||||
<span class="room-meta">
|
<button class="members-toggle" onclick={toggleMembers}>
|
||||||
{props.room?.model_id?.split('/').pop()} · {props.room?.members?.length || 0} members
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
</span>
|
<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>
|
||||||
<div class="room-actions">
|
<div class="room-actions">
|
||||||
<button class="btn btn-ghost btn-icon" onclick={props.cbInvite} title="Invite">
|
<button class="btn btn-ghost btn-icon" onclick={props.cbInvite} title="Invite">
|
||||||
@ -28,6 +37,28 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
</header>
|
||||||
|
|
||||||
<!-- Messages -->
|
<!-- Messages -->
|
||||||
@ -51,7 +82,15 @@
|
|||||||
|
|
||||||
<!-- AI typing indicator (only when NOT streaming content) -->
|
<!-- AI typing indicator (only when NOT streaming content) -->
|
||||||
<div if={props.aiTyping && !props.streamingMessage} class="typing-indicator ai-typing">
|
<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}>
|
<template if={props.aiToolStatus}>
|
||||||
<span class="tool-status-text">
|
<span class="tool-status-text">
|
||||||
{props.aiToolStatus.tool === 'brave_search' ? '🔍 Searching...' : props.aiToolStatus.tool === 'web_fetch' ? '🌐 Reading page...' : '⚙️ Using tool...'}
|
{props.aiToolStatus.tool === 'brave_search' ? '🔍 Searching...' : props.aiToolStatus.tool === 'web_fetch' ? '🌐 Reading page...' : '⚙️ Using tool...'}
|
||||||
@ -75,7 +114,7 @@
|
|||||||
<textarea
|
<textarea
|
||||||
ref="input"
|
ref="input"
|
||||||
class="message-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"
|
rows="1"
|
||||||
oninput={handleInput}
|
oninput={handleInput}
|
||||||
onkeydown={handleKeydown}
|
onkeydown={handleKeydown}
|
||||||
@ -109,6 +148,7 @@
|
|||||||
height: var(--header-height);
|
height: var(--header-height);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.room-header-info h3 {
|
.room-header-info h3 {
|
||||||
@ -116,9 +156,99 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.room-meta {
|
.members-toggle {
|
||||||
font-size: var(--text-xs);
|
background: none;
|
||||||
|
border: none;
|
||||||
color: var(--text-muted);
|
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 {
|
.room-actions {
|
||||||
@ -288,6 +418,25 @@
|
|||||||
state: {
|
state: {
|
||||||
inputValue: '',
|
inputValue: '',
|
||||||
typingDisplay: '',
|
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() {
|
onUpdated() {
|
||||||
@ -343,9 +492,11 @@
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract mentions (simple @ai detection)
|
// Extract mentions (@ai or @{aiName} detection)
|
||||||
const mentions = []
|
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')
|
mentions.push('ai-assistant')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -95,6 +95,19 @@
|
|||||||
></textarea>
|
></textarea>
|
||||||
</div>
|
</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">
|
<div class="modal-actions">
|
||||||
<button type="button" class="btn btn-ghost" onclick={props.cbClose}>Cancel</button>
|
<button type="button" class="btn btn-ghost" onclick={props.cbClose}>Cancel</button>
|
||||||
<button type="submit" class="btn btn-primary">Create Room</button>
|
<button type="submit" class="btn btn-primary">Create Room</button>
|
||||||
@ -343,6 +356,7 @@
|
|||||||
model_id: 'anthropic/claude-sonnet-4',
|
model_id: 'anthropic/claude-sonnet-4',
|
||||||
ai_always_respond: false,
|
ai_always_respond: false,
|
||||||
system_prompt: 'You are a helpful AI assistant participating in a group chat. Be conversational, helpful, and concise.',
|
system_prompt: 'You are a helpful AI assistant participating in a group chat. Be conversational, helpful, and concise.',
|
||||||
|
ai_name: '',
|
||||||
models: [],
|
models: [],
|
||||||
loadingModels: true,
|
loadingModels: true,
|
||||||
showPicker: false,
|
showPicker: false,
|
||||||
@ -350,6 +364,8 @@
|
|||||||
},
|
},
|
||||||
|
|
||||||
onMounted() {
|
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()
|
this.fetchModels()
|
||||||
// Close picker on outside click
|
// Close picker on outside click
|
||||||
this._onDocClick = (e) => {
|
this._onDocClick = (e) => {
|
||||||
@ -430,6 +446,7 @@
|
|||||||
model_id: this.state.model_id,
|
model_id: this.state.model_id,
|
||||||
ai_always_respond: this.state.ai_always_respond,
|
ai_always_respond: this.state.ai_always_respond,
|
||||||
system_prompt: this.state.system_prompt,
|
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 class={'message ' + (props.message?.is_ai ? 'ai-message' : '') + (props.isOwn ? ' own-message' : '')}>
|
||||||
<div if={!props.isOwn} class="message-avatar-col">
|
<div if={!props.isOwn} class="message-avatar-col">
|
||||||
<div class={'message-avatar ' + (props.message?.is_ai ? 'ai-avatar' : '')}>
|
<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>
|
</div>
|
||||||
<div class="message-body">
|
<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(
|
pub async fn accept_invite(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
auth: AuthUser,
|
auth: AuthUser,
|
||||||
Path(token): Path<String>,
|
Path(token): Path<String>,
|
||||||
) -> Result<StatusCode, (StatusCode, String)> {
|
) -> Result<Json<AcceptInviteResponse>, (StatusCode, String)> {
|
||||||
let invite = sqlx::query_as::<_, (String, String, bool)>(
|
let invite = sqlx::query_as::<_, (String, String, bool)>(
|
||||||
"SELECT id, room_id, used FROM invites WHERE token = ?",
|
"SELECT id, room_id, used FROM invites WHERE token = ?",
|
||||||
)
|
)
|
||||||
@ -111,5 +116,5 @@ pub async fn accept_invite(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.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();
|
let room_id = Uuid::new_v4().to_string();
|
||||||
|
|
||||||
sqlx::query(
|
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(&room_id)
|
||||||
.bind(&body.name)
|
.bind(&body.name)
|
||||||
@ -28,6 +28,7 @@ pub async fn create_room(
|
|||||||
.bind(&auth.user_id)
|
.bind(&auth.user_id)
|
||||||
.bind(body.ai_always_respond)
|
.bind(body.ai_always_respond)
|
||||||
.bind(&body.system_prompt)
|
.bind(&body.system_prompt)
|
||||||
|
.bind(&body.ai_name)
|
||||||
.execute(&state.db)
|
.execute(&state.db)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.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(),
|
created_by: auth.user_id.clone(),
|
||||||
ai_always_respond: body.ai_always_respond,
|
ai_always_respond: body.ai_always_respond,
|
||||||
system_prompt: body.system_prompt,
|
system_prompt: body.system_prompt,
|
||||||
|
ai_name: body.ai_name,
|
||||||
created_at: chrono::Utc::now().to_rfc3339(),
|
created_at: chrono::Utc::now().to_rfc3339(),
|
||||||
members: vec![UserPublic {
|
members: vec![UserPublic {
|
||||||
id: auth.user_id,
|
id: auth.user_id,
|
||||||
@ -85,6 +87,7 @@ pub async fn list_rooms(
|
|||||||
created_by: room.created_by,
|
created_by: room.created_by,
|
||||||
ai_always_respond: room.ai_always_respond,
|
ai_always_respond: room.ai_always_respond,
|
||||||
system_prompt: room.system_prompt,
|
system_prompt: room.system_prompt,
|
||||||
|
ai_name: room.ai_name,
|
||||||
created_at: room.created_at,
|
created_at: room.created_at,
|
||||||
members: members
|
members: members
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@ -141,6 +144,7 @@ pub async fn get_room(
|
|||||||
created_by: room.created_by,
|
created_by: room.created_by,
|
||||||
ai_always_respond: room.ai_always_respond,
|
ai_always_respond: room.ai_always_respond,
|
||||||
system_prompt: room.system_prompt,
|
system_prompt: room.system_prompt,
|
||||||
|
ai_name: room.ai_name,
|
||||||
created_at: room.created_at,
|
created_at: room.created_at,
|
||||||
members: members
|
members: members
|
||||||
.into_iter()
|
.into_iter()
|
||||||
|
|||||||
@ -197,14 +197,14 @@ async fn handle_send_message(
|
|||||||
let should_respond = mentions.contains(&ai_user_id.to_string());
|
let should_respond = mentions.contains(&ai_user_id.to_string());
|
||||||
|
|
||||||
// Also check room settings for ai_always_respond
|
// Also check room settings for ai_always_respond
|
||||||
let room = sqlx::query_as::<_, (String, bool, String)>(
|
let room = sqlx::query_as::<_, (String, bool, String, String)>(
|
||||||
"SELECT model_id, ai_always_respond, system_prompt FROM rooms WHERE id = ? AND deleted_at IS NULL",
|
"SELECT model_id, ai_always_respond, system_prompt, ai_name FROM rooms WHERE id = ? AND deleted_at IS NULL",
|
||||||
)
|
)
|
||||||
.bind(room_id)
|
.bind(room_id)
|
||||||
.fetch_optional(&state.db)
|
.fetch_optional(&state.db)
|
||||||
.await;
|
.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,
|
Ok(Some(r)) => r,
|
||||||
_ => return,
|
_ => return,
|
||||||
};
|
};
|
||||||
@ -390,7 +390,7 @@ async fn handle_send_message(
|
|||||||
.bind(&ai_msg_id)
|
.bind(&ai_msg_id)
|
||||||
.bind(room_id)
|
.bind(room_id)
|
||||||
.bind(ai_user_id)
|
.bind(ai_user_id)
|
||||||
.bind("AI Assistant")
|
.bind(&ai_name)
|
||||||
.bind(&ai_response)
|
.bind(&ai_response)
|
||||||
.bind(&ai_now)
|
.bind(&ai_now)
|
||||||
.bind(&ai_meta_json)
|
.bind(&ai_meta_json)
|
||||||
@ -402,7 +402,7 @@ async fn handle_send_message(
|
|||||||
id: ai_msg_id,
|
id: ai_msg_id,
|
||||||
room_id: room_id.to_string(),
|
room_id: room_id.to_string(),
|
||||||
sender_id: ai_user_id.to_string(),
|
sender_id: ai_user_id.to_string(),
|
||||||
sender_name: "AI Assistant".to_string(),
|
sender_name: ai_name.clone(),
|
||||||
content: ai_response,
|
content: ai_response,
|
||||||
mentions: vec![],
|
mentions: vec![],
|
||||||
is_ai: true,
|
is_ai: true,
|
||||||
|
|||||||
@ -70,6 +70,16 @@ async fn main() {
|
|||||||
Err(e) => panic!("Failed to run migration 003: {}", e),
|
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");
|
tracing::info!("Database initialized");
|
||||||
|
|
||||||
let (tx, _rx) = broadcast::channel::<models::BroadcastEvent>(4096);
|
let (tx, _rx) = broadcast::channel::<models::BroadcastEvent>(4096);
|
||||||
|
|||||||
@ -19,6 +19,7 @@ pub struct Room {
|
|||||||
pub created_by: String,
|
pub created_by: String,
|
||||||
pub ai_always_respond: bool,
|
pub ai_always_respond: bool,
|
||||||
pub system_prompt: String,
|
pub system_prompt: String,
|
||||||
|
pub ai_name: String,
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
pub deleted_at: Option<String>,
|
pub deleted_at: Option<String>,
|
||||||
}
|
}
|
||||||
@ -83,6 +84,20 @@ pub struct CreateRoomRequest {
|
|||||||
pub ai_always_respond: bool,
|
pub ai_always_respond: bool,
|
||||||
#[serde(default = "default_system_prompt")]
|
#[serde(default = "default_system_prompt")]
|
||||||
pub system_prompt: String,
|
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 {
|
fn default_system_prompt() -> String {
|
||||||
@ -97,6 +112,7 @@ pub struct RoomResponse {
|
|||||||
pub created_by: String,
|
pub created_by: String,
|
||||||
pub ai_always_respond: bool,
|
pub ai_always_respond: bool,
|
||||||
pub system_prompt: String,
|
pub system_prompt: String,
|
||||||
|
pub ai_name: String,
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
pub members: Vec<UserPublic>,
|
pub members: Vec<UserPublic>,
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user