feat: add profile page with avatar upload and image paste in chat

Add user profile page with custom avatar upload (crop/resize to 256px),
avatar display throughout the app, and MD5-based Gravatar fallback.

Add image paste/attach support in chat with vision model detection via
OpenRouter API. Images can be pasted from clipboard or selected via file
picker, uploaded to the server, and sent alongside messages. The AI
receives images as base64 data URLs for multimodal models. Models with
vision support are indicated with a badge in the model picker.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jason Tudisco 2026-03-09 08:24:38 -06:00
parent 1c7d4d0510
commit 07b4df5544
25 changed files with 1023 additions and 52 deletions

View File

@ -15,6 +15,7 @@
user={state.user} user={state.user}
cb-select-room={selectRoom} cb-select-room={selectRoom}
cb-create-room={() => update({ showCreateModal: true })} cb-create-room={() => update({ showCreateModal: true })}
cb-profile={() => update({ showProfileModal: true })}
cb-logout={handleLogout} cb-logout={handleLogout}
/> />
<main class="chat-main"> <main class="chat-main">
@ -69,6 +70,13 @@
cb-confirm={confirmClearRoom} cb-confirm={confirmClearRoom}
cb-close={() => update({ showClearModal: false })} cb-close={() => update({ showClearModal: false })}
/> />
<profile-page
if={state.showProfileModal}
user={state.user}
cb-profile-update={handleProfileUpdate}
cb-close={() => update({ showProfileModal: false })}
/>
</template> </template>
</div> </div>
@ -157,6 +165,7 @@
showInviteModal: false, showInviteModal: false,
showDeleteModal: false, showDeleteModal: false,
showClearModal: false, showClearModal: false,
showProfileModal: false,
aiTyping: false, aiTyping: false,
aiToolStatus: null, aiToolStatus: null,
streamingMessage: null, streamingMessage: null,
@ -368,8 +377,8 @@
} }
}, },
sendMessage({ content, mentions }) { sendMessage({ content, mentions, imageUrl }) {
ws.sendMessage(this.state.activeRoomId, content, mentions) ws.sendMessage(this.state.activeRoomId, content, mentions, imageUrl)
}, },
handleDeleteRoom(roomId) { handleDeleteRoom(roomId) {
@ -388,6 +397,10 @@
this.update({ messages: [], showClearModal: false }) this.update({ messages: [], showClearModal: false })
}, },
handleProfileUpdate(user) {
this.update({ user })
},
/** Check URL for /invite/:token and stash it for after login if needed */ /** Check URL for /invite/:token and stash it for after login if needed */
checkPendingInvite() { checkPendingInvite() {
const match = window.location.pathname.match(/^\/invite\/(.+)$/) const match = window.location.pathname.match(/^\/invite\/(.+)$/)

View File

@ -118,7 +118,25 @@
<!-- Input --> <!-- Input -->
<div class="message-input-area"> <div class="message-input-area">
<!-- Image preview -->
<div if={state.pendingImage} class="image-preview-bar">
<img src={state.pendingImagePreview} alt="Preview" class="image-preview-thumb" />
<span class="image-preview-name">{state.pendingImage?.name || 'Pasted image'}</span>
<button class="image-preview-remove" onclick={removeImage} title="Remove image">
<svg width="14" height="14" 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="input-wrapper"> <div class="input-wrapper">
<button class="btn btn-ghost attach-btn" onclick={triggerFileInput} title="Attach image">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
<circle cx="8.5" cy="8.5" r="1.5"/>
<polyline points="21 15 16 10 5 21"/>
</svg>
</button>
<input type="file" ref="fileInput" accept="image/png,image/jpeg,image/gif,image/webp" style="display:none" onchange={handleFileSelect} />
<textarea <textarea
ref="input" ref="input"
class="message-input" class="message-input"
@ -126,11 +144,12 @@
rows="1" rows="1"
oninput={handleInput} oninput={handleInput}
onkeydown={handleKeydown} onkeydown={handleKeydown}
onpaste={handlePaste}
></textarea> ></textarea>
<button <button
class="btn btn-primary send-btn" class="btn btn-primary send-btn"
onclick={handleSend} onclick={handleSend}
disabled={!state.inputValue?.trim()} disabled={!state.inputValue?.trim() && !state.pendingImage}
> >
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <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"/> <line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/>
@ -378,6 +397,53 @@
border-top: 1px solid var(--border); border-top: 1px solid var(--border);
} }
.image-preview-bar {
display: flex;
align-items: center;
gap: var(--space-sm);
padding: var(--space-sm) var(--space-sm);
margin-bottom: var(--space-xs);
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: var(--radius-md);
animation: fadeIn 0.15s ease;
}
.image-preview-thumb {
width: 48px;
height: 48px;
object-fit: cover;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
}
.image-preview-name {
flex: 1;
font-size: var(--text-xs);
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.image-preview-remove {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
padding: 4px;
border-radius: var(--radius-sm);
display: flex;
align-items: center;
justify-content: center;
transition: all var(--transition-fast);
}
.image-preview-remove:hover {
color: var(--error, #e53e3e);
background: rgba(229, 62, 62, 0.1);
}
.input-wrapper { .input-wrapper {
display: flex; display: flex;
align-items: flex-end; align-items: flex-end;
@ -393,6 +459,22 @@
border-color: var(--accent); border-color: var(--accent);
} }
.attach-btn {
width: 36px;
height: 36px;
padding: 0;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-md);
color: var(--text-muted);
}
.attach-btn:hover {
color: var(--accent);
}
.message-input { .message-input {
flex: 1; flex: 1;
border: none; border: none;
@ -429,6 +511,7 @@
<script> <script>
import { ws } from '../services/websocket.js' import { ws } from '../services/websocket.js'
import { api } from '../services/api.js'
import { avatarFromEmail as _avatarFromEmail } from '../services/avatar.js' import { avatarFromEmail as _avatarFromEmail } from '../services/avatar.js'
export default { export default {
@ -438,6 +521,9 @@
inputValue: '', inputValue: '',
typingDisplay: '', typingDisplay: '',
showMembers: false, showMembers: false,
pendingImage: null,
pendingImagePreview: null,
uploading: false,
}, },
onMounted() { onMounted() {
@ -451,6 +537,9 @@
onUnmounted() { onUnmounted() {
document.removeEventListener('click', this._closeMembers) document.removeEventListener('click', this._closeMembers)
if (this.state.pendingImagePreview) {
URL.revokeObjectURL(this.state.pendingImagePreview)
}
}, },
toggleMembers(e) { toggleMembers(e) {
@ -493,9 +582,59 @@
} }
}, },
handleSend() { handlePaste(e) {
const content = this.state.inputValue?.trim() const items = e.clipboardData?.items
if (!content) return if (!items) return
for (const item of items) {
if (item.type.startsWith('image/')) {
e.preventDefault()
const file = item.getAsFile()
if (file) this.attachImage(file)
return
}
}
},
triggerFileInput() {
this.$('[ref="fileInput"]')?.click()
},
handleFileSelect(e) {
const file = e.target.files?.[0]
if (file && file.type.startsWith('image/')) {
this.attachImage(file)
}
e.target.value = '' // reset so same file can be re-selected
},
attachImage(file) {
// Revoke old preview URL
if (this.state.pendingImagePreview) {
URL.revokeObjectURL(this.state.pendingImagePreview)
}
const preview = URL.createObjectURL(file)
this.update({
pendingImage: file,
pendingImagePreview: preview,
})
},
removeImage() {
if (this.state.pendingImagePreview) {
URL.revokeObjectURL(this.state.pendingImagePreview)
}
this.update({
pendingImage: null,
pendingImagePreview: null,
})
},
async handleSend() {
const content = this.state.inputValue?.trim() || ''
const hasImage = !!this.state.pendingImage
if (!content && !hasImage) return
// Intercept /clear command — room creator only // Intercept /clear command — room creator only
if (content === '/clear') { if (content === '/clear') {
@ -519,8 +658,33 @@
mentions.push('ai-assistant') mentions.push('ai-assistant')
} }
this.props.cbSend({ content, mentions }) let imageUrl = null
this.update({ inputValue: '' })
// Upload image first if attached
if (hasImage) {
try {
this.update({ uploading: true })
const result = await api.uploadChatImage(this.state.pendingImage)
imageUrl = result.url
} catch (err) {
console.error('Failed to upload image:', err)
this.update({ uploading: false })
return
}
}
// Clean up image preview
if (this.state.pendingImagePreview) {
URL.revokeObjectURL(this.state.pendingImagePreview)
}
this.props.cbSend({ content, mentions, imageUrl })
this.update({
inputValue: '',
pendingImage: null,
pendingImagePreview: null,
uploading: false,
})
const textarea = this.$('textarea') const textarea = this.$('textarea')
if (textarea) { if (textarea) {

View File

@ -32,9 +32,9 @@
</div> </div>
<div class="sidebar-footer"> <div class="sidebar-footer">
<div class="user-info"> <div class="user-info user-info-clickable" onclick={props.cbProfile} title="Edit profile">
<div class="user-avatar"> <div class="user-avatar">
<img src={avatarFromEmail(props.user?.email)} <img src={getUserAvatar()}
alt={props.user?.display_name} alt={props.user?.display_name}
width="32" width="32"
height="32" height="32"
@ -178,6 +178,17 @@
min-width: 0; min-width: 0;
} }
.user-info-clickable {
cursor: pointer;
padding: var(--space-xs);
border-radius: var(--radius-md);
transition: background var(--transition-fast);
}
.user-info-clickable:hover {
background: var(--bg-hover);
}
.user-avatar { .user-avatar {
width: 32px; width: 32px;
height: 32px; height: 32px;
@ -208,10 +219,12 @@
</style> </style>
<script> <script>
import { avatarFromEmail as _avatarFromEmail } from '../services/avatar.js' import { getAvatarUrl } from '../services/avatar.js'
export default { export default {
avatarFromEmail: _avatarFromEmail, getUserAvatar() {
return getAvatarUrl(this.props.user, 32)
},
} }
</script> </script>
</chat-sidebar> </chat-sidebar>

View File

@ -65,6 +65,7 @@
<div class="model-option-meta"> <div class="model-option-meta">
<span class="model-option-id">{model.id}</span> <span class="model-option-id">{model.id}</span>
<span if={model.context_length} class="model-option-ctx">{formatCtx(model.context_length)}</span> <span if={model.context_length} class="model-option-ctx">{formatCtx(model.context_length)}</span>
<span if={model.supports_vision} class="model-option-vision" title="Supports image input">👁 Vision</span>
</div> </div>
</div> </div>
</div> </div>
@ -345,6 +346,15 @@
padding: 1px 5px; padding: 1px 5px;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
} }
.model-option-vision {
background: rgba(99, 102, 241, 0.15);
color: var(--accent);
padding: 1px 6px;
border-radius: var(--radius-sm);
font-size: 10px;
font-weight: 500;
}
</style> </style>
<script> <script>

View File

@ -36,6 +36,9 @@
</div> </div>
</div> </div>
</div> </div>
<div if={props.message?.image_url} class="message-image-wrap">
<img src={props.message.image_url} alt="Attached image" class="message-image" loading="lazy" onclick={openImageFullscreen} />
</div>
<div if={props.isStreaming} class="message-content streaming-content">{props.message?.content}<span class="streaming-cursor">▌</span></div> <div if={props.isStreaming} class="message-content streaming-content">{props.message?.content}<span class="streaming-cursor">▌</span></div>
<div if={!props.isStreaming} class="message-content markdown-content"></div> <div if={!props.isStreaming} class="message-content markdown-content"></div>
<div if={props.message?.is_ai && props.message?.ai_meta} class="ai-stats-bar"> <div if={props.message?.is_ai && props.message?.ai_meta} class="ai-stats-bar">
@ -302,6 +305,27 @@
font-weight: 500; font-weight: 500;
} }
.message-image-wrap {
margin-bottom: 4px;
padding: 4px;
border-radius: var(--radius-md);
overflow: hidden;
}
.message-image {
max-width: 320px;
max-height: 280px;
border-radius: var(--radius-md);
cursor: pointer;
display: block;
object-fit: contain;
transition: opacity 0.15s;
}
.message-image:hover {
opacity: 0.9;
}
.streaming-content { .streaming-content {
white-space: pre-wrap; white-space: pre-wrap;
word-wrap: break-word; word-wrap: break-word;
@ -404,6 +428,11 @@
} }
}, },
openImageFullscreen(e) {
const url = e.target.src
window.open(url, '_blank')
},
copyFullMessage(e) { copyFullMessage(e) {
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()

View File

@ -0,0 +1,292 @@
<profile-page>
<div class="profile-overlay" onclick={handleOverlayClick}>
<div class="profile-card">
<div class="profile-header">
<h2>Profile</h2>
<button class="btn btn-ghost btn-icon" onclick={props.cbClose} title="Close">
<svg width="20" height="20" 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="avatar-section">
<div class="avatar-wrapper">
<img src={currentAvatar()} alt="Avatar" class="avatar-preview" width="96" height="96" />
<label class="avatar-edit-btn" title="Upload avatar">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/>
<circle cx="12" cy="13" r="4"/>
</svg>
<input type="file" accept="image/png,image/jpeg,image/gif,image/webp" onchange={handleAvatarUpload} class="hidden-input" />
</label>
</div>
<p if={state.avatarUploading} class="upload-status">Uploading...</p>
<button if={props.user?.avatar_url} class="btn btn-ghost btn-sm remove-avatar" onclick={handleRemoveAvatar}>
Remove custom avatar
</button>
</div>
<form onsubmit={handleSave}>
<div class="form-group">
<label for="displayName">Display Name</label>
<input
type="text"
id="displayName"
value={state.displayName}
oninput={e => update({ displayName: e.target.value })}
required
/>
</div>
<div class="form-group">
<label>Email</label>
<input type="email" value={props.user?.email} disabled class="input-disabled" />
<span class="form-hint">Email cannot be changed</span>
</div>
<p if={state.error} class="error-text">{state.error}</p>
<p if={state.success} class="success-text">{state.success}</p>
<div class="profile-actions">
<button type="button" class="btn btn-ghost" onclick={props.cbClose}>Cancel</button>
<button type="submit" class="btn btn-primary" disabled={state.saving}>
{state.saving ? 'Saving...' : 'Save Changes'}
</button>
</div>
</form>
</div>
</div>
<style>
.profile-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-out;
}
.profile-card {
width: 100%;
max-width: 440px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: var(--space-xl);
animation: slideUp 0.2s ease-out;
}
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes slideUp { from { transform: translateY(16px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
.profile-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-lg);
}
.profile-header h2 {
font-size: var(--text-lg);
font-weight: 600;
}
.avatar-section {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: var(--space-xl);
}
.avatar-wrapper {
position: relative;
width: 96px;
height: 96px;
}
.avatar-preview {
width: 96px;
height: 96px;
border-radius: var(--radius-full);
object-fit: cover;
border: 3px solid var(--border);
}
.avatar-edit-btn {
position: absolute;
bottom: 0;
right: 0;
width: 32px;
height: 32px;
border-radius: var(--radius-full);
background: var(--accent);
color: white;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background var(--transition-fast);
border: 2px solid var(--bg-secondary);
}
.avatar-edit-btn:hover {
background: var(--accent-hover);
}
.hidden-input {
display: none;
}
.upload-status {
margin-top: var(--space-sm);
font-size: var(--text-sm);
color: var(--text-secondary);
}
.remove-avatar {
margin-top: var(--space-sm);
font-size: var(--text-xs);
color: var(--text-muted);
}
.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);
}
.input-disabled {
opacity: 0.5;
cursor: not-allowed;
}
.form-hint {
display: block;
margin-top: 4px;
font-size: var(--text-xs);
color: var(--text-muted);
}
.error-text {
color: var(--error);
font-size: var(--text-sm);
margin-bottom: var(--space-sm);
}
.success-text {
color: var(--success);
font-size: var(--text-sm);
margin-bottom: var(--space-sm);
}
.profile-actions {
display: flex;
justify-content: flex-end;
gap: var(--space-sm);
margin-top: var(--space-lg);
}
.btn-icon {
width: 36px;
height: 36px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-md);
}
.btn-sm {
padding: var(--space-xs) var(--space-md);
font-size: var(--text-sm);
}
</style>
<script>
import { api, saveAuth } from '../services/api.js'
import { getAvatarUrl } from '../services/avatar.js'
export default {
state: {
displayName: '',
saving: false,
avatarUploading: false,
error: null,
success: null,
},
onMounted() {
this.update({
displayName: this.props.user?.display_name || '',
})
},
currentAvatar() {
return getAvatarUrl(this.props.user, 96)
},
handleOverlayClick(e) {
if (e.target.classList.contains('profile-overlay')) {
this.props.cbClose()
}
},
async handleSave(e) {
e.preventDefault()
this.update({ saving: true, error: null, success: null })
try {
const data = await api.updateProfile({ display_name: this.state.displayName })
saveAuth(data.token, data.user)
this.props.cbProfileUpdate(data.user)
this.update({ saving: false, success: 'Profile updated!' })
} catch (err) {
this.update({ saving: false, error: err.message })
}
},
async handleAvatarUpload(e) {
const file = e.target.files[0]
if (!file) return
if (file.size > 2 * 1024 * 1024) {
this.update({ error: 'Avatar must be under 2MB' })
return
}
this.update({ avatarUploading: true, error: null, success: null })
try {
const data = await api.uploadAvatar(file)
saveAuth(data.token, data.user)
this.props.cbProfileUpdate(data.user)
this.update({ avatarUploading: false, success: 'Avatar updated!' })
} catch (err) {
this.update({ avatarUploading: false, error: err.message })
}
},
async handleRemoveAvatar() {
this.update({ error: null, success: null })
try {
const data = await api.deleteAvatar()
saveAuth(data.token, data.user)
this.props.cbProfileUpdate(data.user)
this.update({ success: 'Avatar removed' })
} catch (err) {
this.update({ error: err.message })
}
},
}
</script>
</profile-page>

View File

@ -10,6 +10,7 @@ import InviteModal from './components/invite-modal.riot'
import DeleteRoomModal from './components/delete-room-modal.riot' import DeleteRoomModal from './components/delete-room-modal.riot'
import ClearConfirmModal from './components/clear-confirm-modal.riot' import ClearConfirmModal from './components/clear-confirm-modal.riot'
import MessageBubble from './components/message-bubble.riot' import MessageBubble from './components/message-bubble.riot'
import ProfilePage from './components/profile-page.riot'
// Register all components // Register all components
register('login-page', LoginPage) register('login-page', LoginPage)
@ -21,6 +22,7 @@ register('invite-modal', InviteModal)
register('delete-room-modal', DeleteRoomModal) register('delete-room-modal', DeleteRoomModal)
register('clear-confirm-modal', ClearConfirmModal) register('clear-confirm-modal', ClearConfirmModal)
register('message-bubble', MessageBubble) register('message-bubble', MessageBubble)
register('profile-page', ProfilePage)
// Mount the app // Mount the app
const mountApp = component(App) const mountApp = component(App)

View File

@ -42,6 +42,24 @@ export const api = {
login: (data) => request('POST', '/auth/login', data), login: (data) => request('POST', '/auth/login', data),
me: () => request('GET', '/auth/me'), me: () => request('GET', '/auth/me'),
// Profile
updateProfile: (data) => request('PUT', '/auth/profile', data),
uploadAvatar: async (file) => {
const formData = new FormData()
formData.append('avatar', file)
const res = await fetch(`${API_BASE}/auth/avatar`, {
method: 'POST',
headers: authHeaders(),
body: formData,
})
if (!res.ok) {
const text = await res.text()
throw new Error(text || `HTTP ${res.status}`)
}
return res.json()
},
deleteAvatar: () => request('DELETE', '/auth/avatar'),
// Rooms // Rooms
listRooms: () => request('GET', '/rooms'), listRooms: () => request('GET', '/rooms'),
createRoom: (data) => request('POST', '/rooms', data), createRoom: (data) => request('POST', '/rooms', data),
@ -58,6 +76,22 @@ export const api = {
// Models // Models
listModels: () => request('GET', '/models'), listModels: () => request('GET', '/models'),
// Upload (chat images)
uploadChatImage: async (file) => {
const formData = new FormData()
formData.append('image', file)
const res = await fetch(`${API_BASE}/upload`, {
method: 'POST',
headers: authHeaders(),
body: formData,
})
if (!res.ok) {
const text = await res.text()
throw new Error(text || `HTTP ${res.status}`)
}
return res.json()
},
// Invites // Invites
createInvite: (data) => request('POST', '/invites', data), createInvite: (data) => request('POST', '/invites', data),
acceptInvite: (token) => request('POST', `/invites/${token}/accept`), acceptInvite: (token) => request('POST', `/invites/${token}/accept`),

View File

@ -1,9 +1,20 @@
/** /**
* Gravatar avatar utilities. * Avatar utilities.
* Uses Gravatar with "monsterid" fallback real photos for users who have * Supports custom uploaded avatars with Gravatar + "monsterid" fallback.
* a Gravatar account, unique monster icons for everyone else.
*/ */
/**
* Get the best avatar URL for a user object.
* Prefers custom avatar_url, falls back to Gravatar.
* @param {object} user - User object with optional avatar_url and email
* @param {number} [size=64] - Pixel size (for Gravatar fallback)
* @returns {string} Avatar URL
*/
export function getAvatarUrl(user, size = 64) {
if (user?.avatar_url) return user.avatar_url
return avatarFromEmail(user?.email, size)
}
/** /**
* Get avatar URL from a pre-computed MD5 hash (used for messages). * Get avatar URL from a pre-computed MD5 hash (used for messages).
* @param {string} hash - MD5 hash of the user's email (from server) * @param {string} hash - MD5 hash of the user's email (from server)

View File

@ -102,14 +102,16 @@ class WebSocketManager {
this.send({ type: 'join_room', room_id: roomId }) this.send({ type: 'join_room', room_id: roomId })
} }
sendMessage(roomId, content, mentions = []) { sendMessage(roomId, content, mentions = [], imageUrl = null) {
console.log('[WS] Sending message to room:', roomId) console.log('[WS] Sending message to room:', roomId)
this.send({ const msg = {
type: 'send_message', type: 'send_message',
room_id: roomId, room_id: roomId,
content, content,
mentions, mentions,
}) }
if (imageUrl) msg.image_url = imageUrl
this.send(msg)
} }
sendTyping(roomId) { sendTyping(roomId) {

View File

@ -31,6 +31,10 @@ export default defineConfig({
target: 'ws://localhost:3001', target: 'ws://localhost:3001',
ws: true, ws: true,
}, },
'/uploads': {
target: 'http://localhost:3001',
changeOrigin: true,
},
}, },
}, },
}) })

2
server/Cargo.lock generated
View File

@ -143,6 +143,7 @@ dependencies = [
"matchit", "matchit",
"memchr", "memchr",
"mime", "mime",
"multer",
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"rustversion", "rustversion",
@ -851,6 +852,7 @@ dependencies = [
"async-trait", "async-trait",
"axum", "axum",
"axum-extra", "axum-extra",
"base64 0.22.1",
"chrono", "chrono",
"dotenvy", "dotenvy",
"futures", "futures",

View File

@ -4,7 +4,7 @@ version = "0.1.0"
edition = "2021" edition = "2021"
[dependencies] [dependencies]
axum = { version = "0.7", features = ["ws", "macros"] } axum = { version = "0.7", features = ["ws", "macros", "multipart"] }
axum-extra = { version = "0.9", features = ["typed-header"] } axum-extra = { version = "0.9", features = ["typed-header"] }
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
tower = "0.4" tower = "0.4"
@ -25,3 +25,4 @@ rand = "0.8"
async-trait = "0.1" async-trait = "0.1"
scraper = "0.22" scraper = "0.22"
md-5 = "0.10" md-5 = "0.10"
base64 = "0.22"

View File

@ -0,0 +1,2 @@
-- Add avatar_url column to users table for custom avatar uploads
ALTER TABLE users ADD COLUMN avatar_url TEXT;

View File

@ -0,0 +1 @@
ALTER TABLE messages ADD COLUMN image_url TEXT;

View File

@ -52,6 +52,7 @@ pub async fn register(
id: user_id, id: user_id,
email: body.email, email: body.email,
display_name: body.display_name, display_name: body.display_name,
avatar_url: None,
}, },
})) }))
} }
@ -60,8 +61,8 @@ pub async fn login(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Json(body): Json<LoginRequest>, Json(body): Json<LoginRequest>,
) -> Result<Json<AuthResponse>, (StatusCode, String)> { ) -> Result<Json<AuthResponse>, (StatusCode, String)> {
let user = sqlx::query_as::<_, (String, String, String, String)>( let user = sqlx::query_as::<_, (String, String, String, String, Option<String>)>(
"SELECT id, email, display_name, password_hash FROM users WHERE email = ?", "SELECT id, email, display_name, password_hash, avatar_url FROM users WHERE email = ?",
) )
.bind(&body.email) .bind(&body.email)
.fetch_optional(&state.db) .fetch_optional(&state.db)
@ -69,7 +70,7 @@ pub async fn login(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::UNAUTHORIZED, "Invalid credentials".into()))?; .ok_or((StatusCode::UNAUTHORIZED, "Invalid credentials".into()))?;
let (user_id, email, display_name, hash) = user; let (user_id, email, display_name, hash, avatar_url) = user;
let parsed_hash = PasswordHash::new(&hash) let parsed_hash = PasswordHash::new(&hash)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
@ -87,14 +88,27 @@ pub async fn login(
id: user_id, id: user_id,
email, email,
display_name, display_name,
avatar_url,
}, },
})) }))
} }
pub async fn me(auth: AuthUser) -> Json<UserPublic> { pub async fn me(
Json(UserPublic { auth: AuthUser,
State(state): State<Arc<AppState>>,
) -> Result<Json<UserPublic>, (StatusCode, String)> {
let avatar_url: Option<String> =
sqlx::query_scalar("SELECT avatar_url FROM users WHERE id = ?")
.bind(&auth.user_id)
.fetch_optional(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.flatten();
Ok(Json(UserPublic {
id: auth.user_id, id: auth.user_id,
email: auth.email, email: auth.email,
display_name: auth.display_name, display_name: auth.display_name,
}) avatar_url,
}))
} }

View File

@ -1,5 +1,7 @@
pub mod auth; pub mod auth;
pub mod invites; pub mod invites;
pub mod models; pub mod models;
pub mod profile;
pub mod rooms; pub mod rooms;
pub mod upload;
pub mod ws; pub mod ws;

View File

@ -28,6 +28,7 @@ pub struct ModelInfo {
pub context_length: Option<u64>, pub context_length: Option<u64>,
pub pricing_prompt: Option<String>, pub pricing_prompt: Option<String>,
pub pricing_completion: Option<String>, pub pricing_completion: Option<String>,
pub supports_vision: bool,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@ -41,6 +42,7 @@ struct OpenRouterModel {
name: String, name: String,
context_length: Option<u64>, context_length: Option<u64>,
pricing: Option<OpenRouterPricing>, pricing: Option<OpenRouterPricing>,
architecture: Option<OpenRouterArchitecture>,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@ -49,6 +51,11 @@ struct OpenRouterPricing {
completion: Option<String>, completion: Option<String>,
} }
#[derive(Debug, Deserialize)]
struct OpenRouterArchitecture {
input_modalities: Option<Vec<String>>,
}
async fn fetch_models(api_key: &str) -> Result<Vec<ModelInfo>, String> { async fn fetch_models(api_key: &str) -> Result<Vec<ModelInfo>, String> {
let client = reqwest::Client::new(); let client = reqwest::Client::new();
@ -75,12 +82,18 @@ async fn fetch_models(api_key: &str) -> Result<Vec<ModelInfo>, String> {
.into_iter() .into_iter()
.map(|m| { .map(|m| {
let pricing = m.pricing.as_ref(); let pricing = m.pricing.as_ref();
let supports_vision = m.architecture
.as_ref()
.and_then(|a| a.input_modalities.as_ref())
.map(|mods| mods.iter().any(|m| m == "image"))
.unwrap_or(false);
ModelInfo { ModelInfo {
id: m.id, id: m.id,
name: m.name, name: m.name,
context_length: m.context_length, context_length: m.context_length,
pricing_prompt: pricing.and_then(|p| p.prompt.clone()), pricing_prompt: pricing.and_then(|p| p.prompt.clone()),
pricing_completion: pricing.and_then(|p| p.completion.clone()), pricing_completion: pricing.and_then(|p| p.completion.clone()),
supports_vision,
} }
}) })
.collect(); .collect();

View File

@ -0,0 +1,181 @@
use axum::{
extract::{Multipart, State},
http::StatusCode,
Json,
};
use std::sync::Arc;
use crate::{
middleware::auth::{create_token, AuthUser},
models::{AuthResponse, UserPublic},
AppState,
};
#[derive(Debug, serde::Deserialize)]
pub struct UpdateProfileRequest {
pub display_name: Option<String>,
}
/// Update the user's display name. Returns a new token + user.
pub async fn update_profile(
auth: AuthUser,
State(state): State<Arc<AppState>>,
Json(body): Json<UpdateProfileRequest>,
) -> Result<Json<AuthResponse>, (StatusCode, String)> {
let display_name = body.display_name.unwrap_or(auth.display_name.clone());
if display_name.trim().is_empty() {
return Err((StatusCode::BAD_REQUEST, "Display name cannot be empty".into()));
}
sqlx::query("UPDATE users SET display_name = ? WHERE id = ?")
.bind(&display_name)
.bind(&auth.user_id)
.execute(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// Fetch the updated avatar_url
let avatar_url: Option<String> =
sqlx::query_scalar("SELECT avatar_url FROM users WHERE id = ?")
.bind(&auth.user_id)
.fetch_optional(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.flatten();
// Issue a new token with the updated display name
let token = create_token(&auth.user_id, &auth.email, &display_name, &state.jwt_secret)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(AuthResponse {
token,
user: UserPublic {
id: auth.user_id,
email: auth.email,
display_name,
avatar_url,
},
}))
}
/// Upload a custom avatar image. Saves to uploads/avatars/<user_id>.ext
pub async fn upload_avatar(
auth: AuthUser,
State(state): State<Arc<AppState>>,
mut multipart: Multipart,
) -> Result<Json<AuthResponse>, (StatusCode, String)> {
let field = multipart
.next_field()
.await
.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?
.ok_or((StatusCode::BAD_REQUEST, "No file uploaded".into()))?;
let content_type = field
.content_type()
.unwrap_or("application/octet-stream")
.to_string();
// Only allow images
let ext = match content_type.as_str() {
"image/png" => "png",
"image/jpeg" | "image/jpg" => "jpg",
"image/gif" => "gif",
"image/webp" => "webp",
_ => return Err((StatusCode::BAD_REQUEST, "Only PNG, JPG, GIF, and WebP images are allowed".into())),
};
let data = field
.bytes()
.await
.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
// Limit to 2MB
if data.len() > 2 * 1024 * 1024 {
return Err((StatusCode::BAD_REQUEST, "Avatar must be under 2MB".into()));
}
// Save to uploads/avatars/
let upload_dir = std::path::Path::new("uploads/avatars");
tokio::fs::create_dir_all(upload_dir)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// Remove any old avatar files for this user
if let Ok(mut entries) = tokio::fs::read_dir(upload_dir).await {
while let Ok(Some(entry)) = entries.next_entry().await {
let name = entry.file_name().to_string_lossy().to_string();
if name.starts_with(&auth.user_id) {
let _ = tokio::fs::remove_file(entry.path()).await;
}
}
}
let filename = format!("{}.{}", auth.user_id, ext);
let filepath = upload_dir.join(&filename);
tokio::fs::write(&filepath, &data)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let avatar_url = format!("/uploads/avatars/{}", filename);
// Update DB
sqlx::query("UPDATE users SET avatar_url = ? WHERE id = ?")
.bind(&avatar_url)
.bind(&auth.user_id)
.execute(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// Issue new token
let token = create_token(&auth.user_id, &auth.email, &auth.display_name, &state.jwt_secret)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(AuthResponse {
token,
user: UserPublic {
id: auth.user_id,
email: auth.email,
display_name: auth.display_name,
avatar_url: Some(avatar_url),
},
}))
}
/// Remove the user's custom avatar, reverting to Gravatar.
pub async fn delete_avatar(
auth: AuthUser,
State(state): State<Arc<AppState>>,
) -> Result<Json<AuthResponse>, (StatusCode, String)> {
// Remove files
let upload_dir = std::path::Path::new("uploads/avatars");
if let Ok(mut entries) = tokio::fs::read_dir(upload_dir).await {
while let Ok(Some(entry)) = entries.next_entry().await {
let name = entry.file_name().to_string_lossy().to_string();
if name.starts_with(&auth.user_id) {
let _ = tokio::fs::remove_file(entry.path()).await;
}
}
}
// Clear in DB
sqlx::query("UPDATE users SET avatar_url = NULL WHERE id = ?")
.bind(&auth.user_id)
.execute(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let token = create_token(&auth.user_id, &auth.email, &auth.display_name, &state.jwt_secret)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(AuthResponse {
token,
user: UserPublic {
id: auth.user_id,
email: auth.email,
display_name: auth.display_name,
avatar_url: None,
},
}))
}

View File

@ -54,6 +54,7 @@ pub async fn create_room(
id: auth.user_id, id: auth.user_id,
email: auth.email, email: auth.email,
display_name: auth.display_name, display_name: auth.display_name,
avatar_url: None,
}], }],
})) }))
} }
@ -72,8 +73,8 @@ pub async fn list_rooms(
let mut result = Vec::new(); let mut result = Vec::new();
for room in rooms { for room in rooms {
let members = sqlx::query_as::<_, (String, String, String)>( let members = sqlx::query_as::<_, (String, String, String, Option<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 = ?", "SELECT u.id, u.email, u.display_name, u.avatar_url FROM users u JOIN room_members rm ON u.id = rm.user_id WHERE rm.room_id = ?",
) )
.bind(&room.id) .bind(&room.id)
.fetch_all(&state.db) .fetch_all(&state.db)
@ -91,10 +92,11 @@ pub async fn list_rooms(
created_at: room.created_at, created_at: room.created_at,
members: members members: members
.into_iter() .into_iter()
.map(|(id, email, display_name)| UserPublic { .map(|(id, email, display_name, avatar_url)| UserPublic {
id, id,
email, email,
display_name, display_name,
avatar_url,
}) })
.collect(), .collect(),
}); });
@ -129,8 +131,8 @@ pub async fn get_room(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::NOT_FOUND, "Room not found".into()))?; .ok_or((StatusCode::NOT_FOUND, "Room not found".into()))?;
let members = sqlx::query_as::<_, (String, String, String)>( let members = sqlx::query_as::<_, (String, String, String, Option<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 = ?", "SELECT u.id, u.email, u.display_name, u.avatar_url FROM users u JOIN room_members rm ON u.id = rm.user_id WHERE rm.room_id = ?",
) )
.bind(&room_id) .bind(&room_id)
.fetch_all(&state.db) .fetch_all(&state.db)
@ -148,10 +150,11 @@ pub async fn get_room(
created_at: room.created_at, created_at: room.created_at,
members: members members: members
.into_iter() .into_iter()
.map(|(id, email, display_name)| UserPublic { .map(|(id, email, display_name, avatar_url)| UserPublic {
id, id,
email, email,
display_name, display_name,
avatar_url,
}) })
.collect(), .collect(),
})) }))
@ -179,8 +182,8 @@ pub async fn get_messages(
// Query messages with user email via LEFT JOIN for Gravatar hash // Query messages with user email via LEFT JOIN for Gravatar hash
let rows = if let Some(before) = &params.before { let rows = if let Some(before) = &params.before {
sqlx::query_as::<_, (String, String, String, String, String, String, bool, String, Option<String>, Option<String>)>( sqlx::query_as::<_, (String, String, String, String, String, String, bool, String, Option<String>, Option<String>, Option<String>)>(
"SELECT m.id, m.room_id, m.sender_id, m.sender_name, m.content, m.mentions, m.is_ai, m.created_at, m.ai_meta, u.email \ "SELECT m.id, m.room_id, m.sender_id, m.sender_name, m.content, m.mentions, m.is_ai, m.created_at, m.ai_meta, m.image_url, u.email \
FROM messages m LEFT JOIN users u ON m.sender_id = u.id \ FROM messages m LEFT JOIN users u ON m.sender_id = u.id \
WHERE m.room_id = ? AND m.created_at < ? ORDER BY m.created_at DESC LIMIT ?", WHERE m.room_id = ? AND m.created_at < ? ORDER BY m.created_at DESC LIMIT ?",
) )
@ -190,8 +193,8 @@ pub async fn get_messages(
.fetch_all(&state.db) .fetch_all(&state.db)
.await .await
} else { } else {
sqlx::query_as::<_, (String, String, String, String, String, String, bool, String, Option<String>, Option<String>)>( sqlx::query_as::<_, (String, String, String, String, String, String, bool, String, Option<String>, Option<String>, Option<String>)>(
"SELECT m.id, m.room_id, m.sender_id, m.sender_name, m.content, m.mentions, m.is_ai, m.created_at, m.ai_meta, u.email \ "SELECT m.id, m.room_id, m.sender_id, m.sender_name, m.content, m.mentions, m.is_ai, m.created_at, m.ai_meta, m.image_url, u.email \
FROM messages m LEFT JOIN users u ON m.sender_id = u.id \ FROM messages m LEFT JOIN users u ON m.sender_id = u.id \
WHERE m.room_id = ? ORDER BY m.created_at DESC LIMIT ?", WHERE m.room_id = ? ORDER BY m.created_at DESC LIMIT ?",
) )
@ -205,7 +208,7 @@ pub async fn get_messages(
let payloads: Vec<MessagePayload> = rows let payloads: Vec<MessagePayload> = rows
.into_iter() .into_iter()
.rev() .rev()
.map(|(id, room_id, sender_id, sender_name, content, mentions, is_ai, created_at, ai_meta_str, email)| { .map(|(id, room_id, sender_id, sender_name, content, mentions, is_ai, created_at, ai_meta_str, image_url, email)| {
let ai_meta = ai_meta_str let ai_meta = ai_meta_str
.as_deref() .as_deref()
.and_then(|s| serde_json::from_str::<crate::models::AiMeta>(s).ok()); .and_then(|s| serde_json::from_str::<crate::models::AiMeta>(s).ok());
@ -223,6 +226,7 @@ pub async fn get_messages(
created_at, created_at,
ai_meta, ai_meta,
avatar_hash, avatar_hash,
image_url,
} }
}) })
.collect(); .collect();

View File

@ -0,0 +1,66 @@
use axum::{
extract::Multipart,
http::StatusCode,
Json,
};
use serde::Serialize;
use uuid::Uuid;
use crate::middleware::auth::AuthUser;
#[derive(Serialize)]
pub struct UploadResponse {
pub url: String,
}
pub async fn upload_chat_image(
_auth: AuthUser,
mut multipart: Multipart,
) -> Result<Json<UploadResponse>, (StatusCode, String)> {
while let Some(field) = multipart
.next_field()
.await
.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?
{
let content_type = field.content_type().unwrap_or("").to_string();
let ext = match content_type.as_str() {
"image/png" => "png",
"image/jpeg" => "jpg",
"image/gif" => "gif",
"image/webp" => "webp",
_ => {
return Err((
StatusCode::BAD_REQUEST,
"Only PNG, JPG, GIF, WebP images are allowed".into(),
))
}
};
let data = field
.bytes()
.await
.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
if data.len() > 5 * 1024 * 1024 {
return Err((StatusCode::BAD_REQUEST, "Image must be under 5MB".into()));
}
let dir = "uploads/chat-images";
tokio::fs::create_dir_all(dir)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let filename = format!("{}.{}", Uuid::new_v4(), ext);
let path = format!("{}/{}", dir, filename);
tokio::fs::write(&path, &data)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
return Ok(Json(UploadResponse {
url: format!("/uploads/chat-images/{}", filename),
}));
}
Err((StatusCode::BAD_REQUEST, "No image provided".into()))
}

View File

@ -122,6 +122,7 @@ async fn handle_socket(socket: WebSocket, state: Arc<AppState>, user_id: String,
room_id, room_id,
content, content,
mentions, mentions,
image_url,
} => { } => {
tracing::info!("User {} sending message to room {}", user_id_clone, room_id); tracing::info!("User {} sending message to room {}", user_id_clone, room_id);
handle_send_message( handle_send_message(
@ -132,6 +133,7 @@ async fn handle_socket(socket: WebSocket, state: Arc<AppState>, user_id: String,
&room_id, &room_id,
&content, &content,
&mentions, &mentions,
image_url.as_deref(),
) )
.await; .await;
} }
@ -156,14 +158,15 @@ async fn handle_send_message(
room_id: &str, room_id: &str,
content: &str, content: &str,
mentions: &[String], mentions: &[String],
image_url: Option<&str>,
) { ) {
let msg_id = Uuid::new_v4().to_string(); let msg_id = Uuid::new_v4().to_string();
let mentions_json = serde_json::to_string(mentions).unwrap_or_else(|_| "[]".to_string()); let mentions_json = serde_json::to_string(mentions).unwrap_or_else(|_| "[]".to_string());
let now = chrono::Utc::now().to_rfc3339(); let now = chrono::Utc::now().to_rfc3339();
// Store in database // Store in database (with image_url)
let _ = sqlx::query( let _ = sqlx::query(
"INSERT INTO messages (id, room_id, sender_id, sender_name, content, mentions, is_ai, created_at) VALUES (?, ?, ?, ?, ?, ?, 0, ?)", "INSERT INTO messages (id, room_id, sender_id, sender_name, content, mentions, is_ai, created_at, image_url) VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?)",
) )
.bind(&msg_id) .bind(&msg_id)
.bind(room_id) .bind(room_id)
@ -172,6 +175,7 @@ async fn handle_send_message(
.bind(content) .bind(content)
.bind(&mentions_json) .bind(&mentions_json)
.bind(&now) .bind(&now)
.bind(image_url)
.execute(&state.db) .execute(&state.db)
.await; .await;
@ -187,7 +191,7 @@ async fn handle_send_message(
created_at: now, created_at: now,
ai_meta: None, ai_meta: None,
avatar_hash: crate::models::gravatar_hash(email), avatar_hash: crate::models::gravatar_hash(email),
image_url: image_url.map(String::from),
}; };
let _ = state.tx.send(BroadcastEvent { let _ = state.tx.send(BroadcastEvent {
@ -226,16 +230,25 @@ async fn handle_send_message(
}, },
}); });
// Fetch recent history // Fetch recent history (including image_url for multimodal support)
let recent_messages = sqlx::query_as::<_, (String, String, bool)>( let recent_messages = sqlx::query_as::<_, (String, String, bool, Option<String>)>(
"SELECT sender_name, content, is_ai FROM messages WHERE room_id = ? ORDER BY created_at DESC LIMIT 50", "SELECT sender_name, content, is_ai, image_url FROM messages WHERE room_id = ? ORDER BY created_at DESC LIMIT 50",
) )
.bind(room_id) .bind(room_id)
.fetch_all(&state.db) .fetch_all(&state.db)
.await .await
.unwrap_or_default(); .unwrap_or_default();
let history: Vec<(String, String, bool)> = recent_messages.into_iter().rev().collect(); // Process history: encode images as base64 data URLs for OpenRouter
let mut history: Vec<(String, String, bool, Option<String>)> = Vec::new();
for (sender_name, msg_content, is_ai, msg_image_url) in recent_messages.into_iter().rev() {
let image_data_url = match &msg_image_url {
Some(url) if !is_ai => encode_image_as_data_url(url).await,
_ => None,
};
history.push((sender_name, msg_content, is_ai, image_data_url));
}
let mut chat_history = openrouter::build_chat_history(&system_prompt, &history); let mut chat_history = openrouter::build_chat_history(&system_prompt, &history);
// Build tools for AI // Build tools for AI
@ -327,7 +340,7 @@ async fn handle_send_message(
chat_history.push(openrouter::ChatMessage { chat_history.push(openrouter::ChatMessage {
role: "tool".into(), role: "tool".into(),
content: Some(tool_result), content: Some(openrouter::Content::Text(tool_result)),
tool_calls: None, tool_calls: None,
tool_call_id: Some(tool_call.id.clone()), tool_call_id: Some(tool_call.id.clone()),
}); });
@ -414,6 +427,7 @@ async fn handle_send_message(
created_at: ai_now, created_at: ai_now,
ai_meta, ai_meta,
avatar_hash: String::new(), avatar_hash: String::new(),
image_url: None,
}; };
let _ = state.tx.send(BroadcastEvent { let _ = state.tx.send(BroadcastEvent {
@ -424,6 +438,29 @@ async fn handle_send_message(
}); });
} }
/// Read a local image file and encode it as a base64 data URL for the OpenRouter API.
async fn encode_image_as_data_url(url: &str) -> Option<String> {
// url is like "/uploads/chat-images/uuid.png"
let file_path = url.strip_prefix('/').unwrap_or(url);
let data = tokio::fs::read(file_path).await.ok()?;
let mime = if file_path.ends_with(".png") {
"image/png"
} else if file_path.ends_with(".jpg") || file_path.ends_with(".jpeg") {
"image/jpeg"
} else if file_path.ends_with(".gif") {
"image/gif"
} else if file_path.ends_with(".webp") {
"image/webp"
} else {
"application/octet-stream"
};
use base64::Engine;
let b64 = base64::engine::general_purpose::STANDARD.encode(&data);
Some(format!("data:{};base64,{}", mime, b64))
}
/// Extract a human-readable input string from tool arguments (for UI display). /// Extract a human-readable input string from tool arguments (for UI display).
fn extract_tool_input(tool_name: &str, arguments: &str) -> String { fn extract_tool_input(tool_name: &str, arguments: &str) -> String {
let args: serde_json::Value = serde_json::from_str(arguments).unwrap_or_default(); let args: serde_json::Value = serde_json::from_str(arguments).unwrap_or_default();

View File

@ -4,7 +4,7 @@ mod models;
mod services; mod services;
use axum::{ use axum::{
routing::{get, post}, routing::{get, post, put},
Router, Router,
}; };
use sqlx::sqlite::SqlitePoolOptions; use sqlx::sqlite::SqlitePoolOptions;
@ -81,6 +81,26 @@ async fn main() {
Err(e) => panic!("Failed to run migration 004: {}", e), Err(e) => panic!("Failed to run migration 004: {}", e),
} }
// Run migration 005 - avatar_url on users
let migration_005 = include_str!("../migrations/005_avatar.sql");
match sqlx::raw_sql(migration_005).execute(&db).await {
Ok(_) => tracing::info!("Migration 005 applied"),
Err(e) if e.to_string().contains("duplicate column") => {
tracing::debug!("Migration 005 already applied, skipping");
}
Err(e) => panic!("Failed to run migration 005: {}", e),
}
// Run migration 006 - image_url on messages
let migration_006 = include_str!("../migrations/006_image_url.sql");
match sqlx::raw_sql(migration_006).execute(&db).await {
Ok(_) => tracing::info!("Migration 006 applied"),
Err(e) if e.to_string().contains("duplicate column") => {
tracing::debug!("Migration 006 already applied, skipping");
}
Err(e) => panic!("Failed to run migration 006: {}", 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);
@ -106,17 +126,24 @@ async fn main() {
.route("/api/auth/register", post(handlers::auth::register)) .route("/api/auth/register", post(handlers::auth::register))
.route("/api/auth/login", post(handlers::auth::login)) .route("/api/auth/login", post(handlers::auth::login))
.route("/api/auth/me", get(handlers::auth::me)) .route("/api/auth/me", get(handlers::auth::me))
// Profile routes
.route("/api/auth/profile", put(handlers::profile::update_profile))
.route("/api/auth/avatar", post(handlers::profile::upload_avatar).delete(handlers::profile::delete_avatar))
// Room routes // Room routes
.route("/api/rooms", get(handlers::rooms::list_rooms).post(handlers::rooms::create_room)) .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", 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/messages", get(handlers::rooms::get_messages))
.route("/api/rooms/:room_id/join", post(handlers::rooms::join_room)) .route("/api/rooms/:room_id/join", post(handlers::rooms::join_room))
.route("/api/rooms/:room_id/clear", post(handlers::rooms::clear_room)) .route("/api/rooms/:room_id/clear", post(handlers::rooms::clear_room))
// Upload (chat images)
.route("/api/upload", post(handlers::upload::upload_chat_image))
// Models // Models
.route("/api/models", get(handlers::models::list_models)) .route("/api/models", get(handlers::models::list_models))
// Invite routes // Invite routes
.route("/api/invites", post(handlers::invites::create_invite)) .route("/api/invites", post(handlers::invites::create_invite))
.route("/api/invites/:token/accept", post(handlers::invites::accept_invite)) .route("/api/invites/:token/accept", post(handlers::invites::accept_invite))
// Uploaded files (avatars)
.nest_service("/uploads", ServeDir::new("uploads"))
// WebSocket // WebSocket
.route("/ws", get(handlers::ws::ws_handler)) .route("/ws", get(handlers::ws::ws_handler))
.layer(cors) .layer(cors)

View File

@ -74,6 +74,8 @@ pub struct UserPublic {
pub id: String, pub id: String,
pub email: String, pub email: String,
pub display_name: String, pub display_name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub avatar_url: Option<String>,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@ -134,6 +136,8 @@ pub enum WsClientMessage {
content: String, content: String,
#[serde(default)] #[serde(default)]
mentions: Vec<String>, mentions: Vec<String>,
#[serde(default)]
image_url: Option<String>,
}, },
#[serde(rename = "join_room")] #[serde(rename = "join_room")]
JoinRoom { room_id: String }, JoinRoom { room_id: String },
@ -207,6 +211,8 @@ pub struct MessagePayload {
pub ai_meta: Option<AiMeta>, pub ai_meta: Option<AiMeta>,
#[serde(default)] #[serde(default)]
pub avatar_hash: String, pub avatar_hash: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub image_url: Option<String>,
} }
/// Compute Gravatar-compatible MD5 hash from an email address. /// Compute Gravatar-compatible MD5 hash from an email address.

View File

@ -16,11 +16,34 @@ struct ChatRequest {
stream: Option<bool>, stream: Option<bool>,
} }
/// Content can be a plain text string or multimodal (text + image) parts.
/// Serializes to a JSON string or a JSON array, matching the OpenAI/OpenRouter API.
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(untagged)]
pub enum Content {
Text(String),
Parts(Vec<ContentPart>),
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(tag = "type")]
pub enum ContentPart {
#[serde(rename = "text")]
Text { text: String },
#[serde(rename = "image_url")]
ImageUrl { image_url: ImageUrlData },
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ImageUrlData {
pub url: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)] #[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ChatMessage { pub struct ChatMessage {
pub role: String, pub role: String,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub content: Option<String>, pub content: Option<Content>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub tool_calls: Option<Vec<ToolCall>>, pub tool_calls: Option<Vec<ToolCall>>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
@ -350,7 +373,7 @@ pub async fn chat_completion_stream(
// AI requested tool calls // AI requested tool calls
let assistant_msg = ChatMessage { let assistant_msg = ChatMessage {
role: "assistant".into(), role: "assistant".into(),
content: if full_content.is_empty() { None } else { Some(full_content) }, content: if full_content.is_empty() { None } else { Some(Content::Text(full_content)) },
tool_calls: Some(tool_call_accum), tool_calls: Some(tool_call_accum),
tool_call_id: None, tool_call_id: None,
}; };
@ -366,29 +389,47 @@ pub async fn chat_completion_stream(
/// Build the message history for OpenRouter from stored messages. /// Build the message history for OpenRouter from stored messages.
/// Includes the system prompt as the first message. /// Includes the system prompt as the first message.
/// Messages with image data URLs will be sent as multimodal content.
pub fn build_chat_history( pub fn build_chat_history(
system_prompt: &str, system_prompt: &str,
messages: &[(String, String, bool)], // (sender_name, content, is_ai) messages: &[(String, String, bool, Option<String>)], // (sender_name, content, is_ai, image_data_url)
) -> Vec<ChatMessage> { ) -> Vec<ChatMessage> {
let mut history = vec![ChatMessage { let mut history = vec![ChatMessage {
role: "system".to_string(), role: "system".to_string(),
content: Some(system_prompt.to_string()), content: Some(Content::Text(system_prompt.to_string())),
tool_calls: None, tool_calls: None,
tool_call_id: None, tool_call_id: None,
}]; }];
for (sender_name, content, is_ai) in messages { for (sender_name, content, is_ai, image_data_url) in messages {
if *is_ai { if *is_ai {
history.push(ChatMessage { history.push(ChatMessage {
role: "assistant".to_string(), role: "assistant".to_string(),
content: Some(content.clone()), content: Some(Content::Text(content.clone())),
tool_calls: None, tool_calls: None,
tool_call_id: None, tool_call_id: None,
}); });
} else { } else {
let text = if content.is_empty() {
format!("[{}] shared an image:", sender_name)
} else {
format!("[{}]: {}", sender_name, content)
};
let msg_content = if let Some(data_url) = image_data_url {
Content::Parts(vec![
ContentPart::Text { text },
ContentPart::ImageUrl {
image_url: ImageUrlData { url: data_url.clone() },
},
])
} else {
Content::Text(text)
};
history.push(ChatMessage { history.push(ChatMessage {
role: "user".to_string(), role: "user".to_string(),
content: Some(format!("[{}]: {}", sender_name, content)), content: Some(msg_content),
tool_calls: None, tool_calls: None,
tool_call_id: None, tool_call_id: None,
}); });