Skip to content

Commit 8a7614a

Browse files
committed
Add new Settings Page to allow users to upload new User and Agent avatars
1 parent 7ef8d20 commit 8a7614a

5 files changed

Lines changed: 231 additions & 20 deletions

File tree

llms/ui/App.mjs

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,20 +5,32 @@ import { AppContext } from "./ctx.mjs"
55
// Vertical Sidebar Icons
66
const LeftBar = {
77
template: `
8-
<div class="select-none flex flex-col space-y-2 pt-2.5 px-1">
9-
<div v-for="(icon, id) in $ctx.left" :key="id" class="relative flex items-center justify-center">
10-
<component :is="icon.component"
11-
class="size-7 p-1 cursor-pointer text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 rounded block"
12-
:class="{ 'bg-gray-200 dark:bg-gray-700' : icon.isActive({ ...$layout }) }"
13-
@mouseenter="tooltip = icon.id"
14-
@mouseleave="tooltip = ''"
15-
/>
16-
<div v-if="tooltip === icon.id && !icon.isActive({ ...$layout })"
17-
class="absolute left-full top-1/2 -translate-y-1/2 ml-2 px-2 py-1 text-xs text-white bg-gray-900 dark:bg-gray-800 rounded shadow-md z-50 whitespace-nowrap pointer-events-none" style="z-index: 60">
18-
{{icon.title ?? icon.name}}
19-
</div>
8+
<div class="select-none flex flex-col justify-between h-full">
9+
<!-- top icons -->
10+
<div class="flex flex-col space-y-2 pt-2.5 px-1">
11+
<div v-for="(icon, id) in $ctx.left" :key="id" class="relative flex items-center justify-center">
12+
<component :is="icon.component"
13+
class="size-7 p-1 cursor-pointer text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 rounded block"
14+
:class="{ 'bg-gray-200 dark:bg-gray-700' : icon.isActive({ ...$layout }) }"
15+
@mouseenter="tooltip = icon.id"
16+
@mouseleave="tooltip = ''"
17+
/>
18+
<div v-if="tooltip === icon.id && !icon.isActive({ ...$layout })"
19+
class="absolute left-full top-1/2 -translate-y-1/2 ml-2 px-2 py-1 text-xs text-white bg-gray-900 dark:bg-gray-800 rounded shadow-md z-50 whitespace-nowrap pointer-events-none" style="z-index: 60">
20+
{{icon.title ?? icon.name}}
21+
</div>
22+
</div>
23+
</div>
24+
<!-- bottom icons -->
25+
<div>
26+
<div title="Settings" @click="$router.push($route.path == '/settings' ? '/' : '/settings')">
27+
<svg class="size-7 p-1 cursor-pointer text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 rounded block"
28+
:class="{ 'bg-gray-200 dark:bg-gray-700' : $route.path == '/settings' }"
29+
xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="M19 7.5h-7.628a2.251 2.251 0 0 0-4.244 0H5V9h2.128a2.25 2.25 0 0 0 4.244 0H19zm0 7.5h-2.128a2.251 2.251 0 0 0-4.244 0H5v1.5h7.628a2.251 2.251 0 0 0 4.244 0H19z"/></svg>
30+
</div>
2031
</div>
2132
</div>
33+
2234
`,
2335
setup() {
2436
const tooltip = ref('')

llms/ui/app.css

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -929,6 +929,9 @@
929929
.w-16 {
930930
width: calc(var(--spacing) * 16);
931931
}
932+
.w-20 {
933+
width: calc(var(--spacing) * 20);
934+
}
932935
.w-28 {
933936
width: calc(var(--spacing) * 28);
934937
}

llms/ui/ctx.mjs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,9 @@ export class AppContext {
136136
this.marked = marked
137137
this.markedFallback = markedFallback
138138

139-
this.state = reactive({})
139+
this.state = reactive({
140+
cacheBreaker: 1
141+
})
140142
this.events = new EventBus()
141143
this.modalComponents = {}
142144
this.extensions = []
@@ -190,6 +192,15 @@ export class AppContext {
190192
getColorScheme() {
191193
return document.documentElement.classList.contains('dark') ? 'dark' : 'light'
192194
}
195+
getUserAvatar() {
196+
return this.resolveUrl(`/avatar/user?mode=${this.getColorScheme()}&t=${this.state.cacheBreaker}`)
197+
}
198+
getAgentAvatar() {
199+
return this.resolveUrl(`/agents/avatar?mode=${this.getColorScheme()}&t=${this.state.cacheBreaker}`)
200+
}
201+
incCacheBreaker() {
202+
this.state.cacheBreaker++
203+
}
193204
getPrefs() {
194205
return this.prefs
195206
}

llms/ui/modules/chat/ChatBody.mjs

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -879,17 +879,14 @@ export const ToolCall = {
879879

880880
export const UserAvatar = {
881881
template: `
882-
<img class="size-8 rounded-full" :src="'/avatar/user?mode=' + $ctx.getColorScheme()" />
882+
<img class="size-8 rounded-full" :src="$ctx.getUserAvatar()" />
883883
`
884884
}
885885

886886
export const AgentAvatar = {
887887
template: `
888-
<img class="size-8 rounded-full bg-gray-200 dark:bg-gray-600" :src="'/agents/avatar/' + role + '?mode=' + $ctx.getColorScheme()" />
889-
`,
890-
props: {
891-
role: String
892-
}
888+
<img class="size-8 rounded-full bg-gray-200 dark:bg-gray-600" :src="$ctx.getAgentAvatar()" />
889+
`
893890
}
894891

895892
export const ChatBody = {
@@ -930,7 +927,7 @@ export const ChatBody = {
930927
<!-- Avatar outside the bubble -->
931928
<div class="flex-shrink-0 flex flex-col justify-center">
932929
<UserAvatar v-if="message.role === 'user'" />
933-
<AgentAvatar v-else :role="message.role" />
930+
<AgentAvatar v-else />
934931
935932
<!-- Delete button (shown on hover) -->
936933
<button type="button" @click.stop="$threads.deleteMessageFromThread(currentThread.id, message.timestamp)"

llms/ui/modules/layout.mjs

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,190 @@ const ErrorViewer = {
198198
</div>
199199
`,
200200
setup() {
201+
}
202+
}
201203

204+
const SettingsPage = {
205+
template: `
206+
<div class="max-w-2xl mx-auto p-6">
207+
<h1 class="text-2xl font-bold text-gray-900 dark:text-gray-100 mb-8">Settings</h1>
208+
209+
<!-- User Avatar Section -->
210+
<div class="mb-8 p-6 bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700">
211+
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">User Avatar</h2>
212+
<div class="flex items-center gap-6">
213+
<label for="userAvatarInput" class="relative group cursor-pointer">
214+
<img
215+
:src="userAvatarUrl"
216+
class="w-20 h-20 rounded-full object-cover border-2 border-gray-200 dark:border-gray-600 shadow-md"
217+
alt="User Avatar"
218+
/>
219+
<div class="absolute inset-0 rounded-full bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
220+
<svg class="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
221+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"/>
222+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"/>
223+
</svg>
224+
</div>
225+
<input id="userAvatarInput" type="file" class="hidden" accept="image/*" @change="uploadUserAvatar" />
226+
</label>
227+
<div class="flex-1">
228+
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3">
229+
Upload a new image for your avatar
230+
</p>
231+
<div class="flex items-center gap-3">
232+
<label for="userAvatarInput" class="cursor-pointer px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg transition-colors shadow-sm">
233+
<span>Choose File</span>
234+
</label>
235+
<span v-if="userUploading" class="text-sm text-gray-500 dark:text-gray-400 flex items-center gap-2">
236+
<svg class="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
237+
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
238+
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
239+
</svg>
240+
Uploading...
241+
</span>
242+
<span v-if="userSuccess" class="text-sm text-green-600 dark:text-green-400 flex items-center gap-1">
243+
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
244+
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
245+
</svg>
246+
Uploaded!
247+
</span>
248+
</div>
249+
<p v-if="userError" class="mt-2 text-sm text-red-600 dark:text-red-400">{{ userError }}</p>
250+
</div>
251+
</div>
252+
</div>
253+
254+
<!-- Agent Avatar Section -->
255+
<div class="p-6 bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700">
256+
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">Agent Avatar</h2>
257+
<div class="flex items-center gap-6">
258+
<label for="agentAvatarInput" class="relative group cursor-pointer">
259+
<img
260+
:src="agentAvatarUrl"
261+
class="w-20 h-20 rounded-full object-cover border-2 border-gray-200 dark:border-gray-600 shadow-md"
262+
alt="Agent Avatar"
263+
/>
264+
<div class="absolute inset-0 rounded-full bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
265+
<svg class="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
266+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"/>
267+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"/>
268+
</svg>
269+
</div>
270+
<input id="agentAvatarInput" type="file" class="hidden" accept="image/*" @change="uploadAgentAvatar" />
271+
</label>
272+
<div class="flex-1">
273+
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3">
274+
Upload a new image for your Agent's avatar
275+
</p>
276+
<div class="flex items-center gap-3">
277+
<label for="agentAvatarInput" class="cursor-pointer px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg transition-colors shadow-sm">
278+
<span>Choose File</span>
279+
</label>
280+
<span v-if="agentUploading" class="text-sm text-gray-500 dark:text-gray-400 flex items-center gap-2">
281+
<svg class="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
282+
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
283+
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
284+
</svg>
285+
Uploading...
286+
</span>
287+
<span v-if="agentSuccess" class="text-sm text-green-600 dark:text-green-400 flex items-center gap-1">
288+
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
289+
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
290+
</svg>
291+
Uploaded!
292+
</span>
293+
</div>
294+
<p v-if="agentError" class="mt-2 text-sm text-red-600 dark:text-red-400">{{ agentError }}</p>
295+
</div>
296+
</div>
297+
</div>
298+
</div>
299+
`,
300+
setup() {
301+
const ctx = inject('ctx')
302+
303+
const userAvatarUrl = computed(() => ctx.getUserAvatar())
304+
const agentAvatarUrl = computed(() => ctx.getAgentAvatar())
305+
306+
const userUploading = ref(false)
307+
const userSuccess = ref(false)
308+
const userError = ref('')
309+
const agentUploading = ref(false)
310+
const agentSuccess = ref(false)
311+
const agentError = ref('')
312+
313+
async function uploadUserAvatar(event) {
314+
const file = event.target.files?.[0]
315+
if (!file) return
316+
317+
userUploading.value = true
318+
userSuccess.value = false
319+
userError.value = ''
320+
321+
try {
322+
const formData = new FormData()
323+
formData.append('file', file)
324+
325+
const response = await ctx.postForm('/user/avatar', { body: formData })
326+
const result = await response.json()
327+
328+
if (response.ok && result.success) {
329+
userSuccess.value = true
330+
ctx.incCacheBreaker()
331+
setTimeout(() => { userSuccess.value = false }, 3000)
332+
} else {
333+
userError.value = result.message || 'Upload failed'
334+
}
335+
} catch (e) {
336+
userError.value = e.message || 'Upload failed'
337+
} finally {
338+
userUploading.value = false
339+
event.target.value = ''
340+
}
341+
}
342+
343+
async function uploadAgentAvatar(event) {
344+
const file = event.target.files?.[0]
345+
if (!file) return
346+
347+
agentUploading.value = true
348+
agentSuccess.value = false
349+
agentError.value = ''
350+
351+
try {
352+
const formData = new FormData()
353+
formData.append('file', file)
354+
355+
const response = await ctx.postForm('/agents/avatar', { body: formData })
356+
const result = await response.json()
357+
358+
if (response.ok && result.success) {
359+
agentSuccess.value = true
360+
ctx.incCacheBreaker()
361+
setTimeout(() => { agentSuccess.value = false }, 3000)
362+
} else {
363+
agentError.value = result.message || 'Upload failed'
364+
}
365+
} catch (e) {
366+
agentError.value = e.message || 'Upload failed'
367+
} finally {
368+
agentUploading.value = false
369+
event.target.value = ''
370+
}
371+
}
372+
373+
return {
374+
userAvatarUrl,
375+
agentAvatarUrl,
376+
userUploading,
377+
userSuccess,
378+
userError,
379+
agentUploading,
380+
agentSuccess,
381+
agentError,
382+
uploadUserAvatar,
383+
uploadAgentAvatar,
384+
}
202385
}
203386
}
204387

@@ -210,6 +393,11 @@ export default {
210393
Avatar,
211394
SignIn,
212395
ErrorViewer,
396+
SettingsPage,
213397
})
398+
399+
ctx.routes.push(...[
400+
{ path: '/settings', component: SettingsPage },
401+
])
214402
}
215403
}

0 commit comments

Comments
 (0)