Skip to content

Commit 0f692b0

Browse files
committed
Add support for customizable user and agent avatars
1 parent 47f28e2 commit 0f692b0

7 files changed

Lines changed: 158 additions & 52 deletions

File tree

llms/extensions/app/README.md

Lines changed: 1 addition & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,2 @@
1-
# App Extension
1+
# Customizing User and Agent Avatars
22

3-
This extension provides the core application logic and data persistence for the LLMs platform.
4-
5-
## Data Storage & Architecture
6-
7-
### Server-Side SQLite Migration
8-
The application has migrated from client-side IndexedDB storage to a robust server-side SQLite solution. This architectural shift ensures better data consistency, improved performance, and enables multi-device access to your chat history.
9-
10-
### Asset Management
11-
To keep the database efficient and portable, binary assets (images, audio, etc.) are not stored directly in the SQLite database. Instead:
12-
- All generated assets are stored in the local file system cache at `~/.llms/cache`.
13-
- The database stores only **relative URLs** pointing to these assets.
14-
- This approach allows for efficient caching and serving of static media.
15-
16-
### Concurrency Model
17-
To ensure data integrity and high performance without complex locking mechanisms, the system utilizes a **single background thread** for managing all write operations to the database. This design improves concurrency handling and eliminates database locking issues during high-load scenarios.
18-
19-
### Multi-Tenancy & Security
20-
When authentication is enabled, data isolation is automatically enforced. All core tables, including `threads` and `requests`, are scoped to the authenticated user, ensuring that users can only access their own data.

llms/extensions/app/__init__.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -431,6 +431,82 @@ async def compact_thread(request):
431431

432432
ctx.add_post("threads/{id}/compact", compact_thread)
433433

434+
async def get_user_avatar(req):
435+
user = ctx.get_username(req)
436+
mode = req.query.get("mode", "light")
437+
438+
# Cache for 1 hour # "Cache-Control": "public, max-age=3600",
439+
headers = {"Content-Type": "image/svg+xml"}
440+
441+
candidate_paths = [
442+
os.path.join(ctx.get_user_path(user=user), "avatar." + mode + ".png"),
443+
os.path.join(ctx.get_user_path(user=user), "avatar." + mode + ".svg"),
444+
os.path.join(ctx.get_user_path(user=user), "avatar.png"),
445+
os.path.join(ctx.get_user_path(user=user), "avatar.svg"),
446+
os.path.join(ctx.get_user_path(), "avatar." + mode + ".png"),
447+
os.path.join(ctx.get_user_path(), "avatar." + mode + ".svg"),
448+
os.path.join(ctx.get_user_path(), "avatar.png"),
449+
os.path.join(ctx.get_user_path(), "avatar.svg"),
450+
]
451+
452+
for path in candidate_paths:
453+
if os.path.exists(path):
454+
headers["Content-Type"] = "image/png" if path.endswith(".png") else "image/svg+xml"
455+
return web.FileResponse(path, headers=headers)
456+
457+
# Fall back to default 'user' avatar
458+
bg_color = "#1e3a8a" if mode == "dark" else "#bfdbfe"
459+
text_color = "#f3f4f6" if mode == "dark" else "#111827"
460+
461+
default_avatar = f"""
462+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" style="color:{text_color}">
463+
<circle cx="16" cy="16" r="16" fill="{bg_color}"/>
464+
<g transform="translate(4, 4)" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
465+
<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>
466+
</g>
467+
</svg>
468+
"""
469+
return web.Response(text=default_avatar, headers=headers)
470+
471+
ctx.add_get("/avatar/user", get_user_avatar)
472+
473+
async def get_agent_avatar(req):
474+
role = req.match_info["role"]
475+
mode = req.query.get("mode", "light")
476+
477+
# Cache for 1 hour # "Cache-Control": "public, max-age=3600",
478+
headers = {"Content-Type": "image/svg+xml"}
479+
480+
candidate_paths = [
481+
os.path.join(ctx.get_user_path(), role + "." + mode + ".png"),
482+
os.path.join(ctx.get_user_path(), role + "." + mode + ".svg"),
483+
os.path.join(ctx.get_user_path(), role + ".png"),
484+
os.path.join(ctx.get_user_path(), role + ".svg"),
485+
os.path.join(ctx.get_user_path(), "agent." + mode + ".png"),
486+
os.path.join(ctx.get_user_path(), "agent." + mode + ".svg"),
487+
os.path.join(ctx.get_user_path(), "agent.png"),
488+
os.path.join(ctx.get_user_path(), "agent.svg"),
489+
]
490+
491+
for path in candidate_paths:
492+
if os.path.exists(path):
493+
headers["Content-Type"] = "image/png" if path.endswith(".png") else "image/svg+xml"
494+
return web.FileResponse(path, headers=headers)
495+
496+
# Fall back to default 'agent' avatar
497+
bg_color = "#1f2937" if mode == "dark" else "#eceef1"
498+
text_color = "#f3f4f6" if mode == "dark" else "#111827"
499+
500+
default_avatar = f"""
501+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" style="color:{text_color}">
502+
<circle cx="16" cy="16" r="16" fill="{bg_color}"/>
503+
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 20v-8a2.667 2.667 0 1 1 5.333 0v8m-5.333-4h5.333m5.334-6.667v10.667" transform="translate(2.667, 1.5)"/>
504+
</svg>
505+
"""
506+
return web.Response(text=default_avatar, headers=headers)
507+
508+
ctx.add_get("/agents/avatar/{role}", get_agent_avatar)
509+
434510
async def chat_request(openai_request, context):
435511
chat = openai_request
436512
user = context.get("user", None)

llms/ui/App.mjs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,8 +170,9 @@ export default {
170170
<div id="main" :class="$ctx.cls('main', 'flex-1 flex flex-col')">
171171
<div id="main-inner" :class="$ctx.cls('main-inner', 'flex flex-col h-full w-full overflow-hidden')">
172172
<div v-if="$ai.hasAccess" id="header" :class="$ctx.cls('header', 'py-1 pr-1 flex items-center justify-between shrink-0')">
173-
<div>
173+
<div class="flex items-center gap-2">
174174
<ModelSelector :models="$state.models" v-model="$state.selectedModel" />
175+
<component v-for="(c, id) in $ctx.leftTop" :is="c.component" />
175176
</div>
176177
<div class="flex items-center gap-2">
177178
<TopBar id="top-bar" />

llms/ui/app.css

Lines changed: 28 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,6 @@
108108
--color-fuchsia-900: oklch(40.1% 0.17 325.612);
109109
--color-slate-50: oklch(98.4% 0.003 247.858);
110110
--color-slate-200: oklch(92.9% 0.013 255.508);
111-
--color-slate-300: oklch(86.9% 0.022 252.894);
112111
--color-slate-400: oklch(70.4% 0.04 256.788);
113112
--color-slate-500: oklch(55.4% 0.046 257.417);
114113
--color-slate-700: oklch(37.2% 0.044 257.287);
@@ -201,7 +200,6 @@
201200
--default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
202201
--default-font-family: var(--font-sans);
203202
--default-mono-font-family: var(--font-mono);
204-
--default-ring-color: hsl(var(--ring));
205203
}
206204
}
207205
@layer base {
@@ -604,9 +602,6 @@
604602
.mt-8 {
605603
margin-top: calc(var(--spacing) * 8);
606604
}
607-
.mt-10 {
608-
margin-top: calc(var(--spacing) * 10);
609-
}
610605
.mt-12 {
611606
margin-top: calc(var(--spacing) * 12);
612607
}
@@ -821,9 +816,6 @@
821816
.h-22 {
822817
height: calc(var(--spacing) * 22);
823818
}
824-
.h-24 {
825-
height: calc(var(--spacing) * 24);
826-
}
827819
.h-40 {
828820
height: calc(var(--spacing) * 40);
829821
}
@@ -1682,6 +1674,9 @@
16821674
.bg-blue-100 {
16831675
background-color: var(--color-blue-100);
16841676
}
1677+
.bg-blue-200 {
1678+
background-color: var(--color-blue-200);
1679+
}
16851680
.bg-blue-500 {
16861681
background-color: var(--color-blue-500);
16871682
}
@@ -1748,9 +1743,6 @@
17481743
background-color: color-mix(in oklab, var(--color-gray-500) 75%, transparent);
17491744
}
17501745
}
1751-
.bg-gray-600 {
1752-
background-color: var(--color-gray-600);
1753-
}
17541746
.bg-gray-700 {
17551747
background-color: var(--color-gray-700);
17561748
}
@@ -2532,9 +2524,6 @@
25322524
.text-sky-600 {
25332525
color: var(--color-sky-600);
25342526
}
2535-
.text-slate-300 {
2536-
color: var(--color-slate-300);
2537-
}
25382527
.text-slate-500 {
25392528
color: var(--color-slate-500);
25402529
}
@@ -4217,6 +4206,11 @@
42174206
max-width: 65ch;
42184207
}
42194208
}
4209+
.sm\:max-w-sm {
4210+
@media (width >= 40rem) {
4211+
max-width: var(--container-sm);
4212+
}
4213+
}
42204214
.sm\:flex-initial {
42214215
@media (width >= 40rem) {
42224216
flex: 0 auto;
@@ -4420,6 +4414,11 @@
44204414
width: auto;
44214415
}
44224416
}
4417+
.md\:max-w-lg {
4418+
@media (width >= 48rem) {
4419+
max-width: var(--container-lg);
4420+
}
4421+
}
44234422
.md\:max-w-xl {
44244423
@media (width >= 48rem) {
44254424
max-width: var(--container-xl);
@@ -4594,6 +4593,11 @@
45944593
max-width: var(--breakpoint-md);
45954594
}
45964595
}
4596+
.lg\:max-w-xl {
4597+
@media (width >= 64rem) {
4598+
max-width: var(--container-xl);
4599+
}
4600+
}
45974601
.lg\:flex-initial {
45984602
@media (width >= 64rem) {
45994603
flex: 0 auto;
@@ -4659,6 +4663,11 @@
46594663
width: calc(var(--spacing) * 80);
46604664
}
46614665
}
4666+
.xl\:max-w-2xl {
4667+
@media (width >= 80rem) {
4668+
max-width: var(--container-2xl);
4669+
}
4670+
}
46624671
.xl\:max-w-3xl {
46634672
@media (width >= 80rem) {
46644673
max-width: var(--container-3xl);
@@ -5308,6 +5317,11 @@
53085317
fill: var(--color-gray-300);
53095318
}
53105319
}
5320+
.dark\:fill-gray-500 {
5321+
&:where(.dark, .dark *) {
5322+
fill: var(--color-gray-500);
5323+
}
5324+
}
53115325
.dark\:text-black {
53125326
&:where(.dark, .dark *) {
53135327
color: var(--color-black);

llms/ui/ctx.mjs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,7 @@ export class AppContext {
150150
this.threadFooterComponents = {}
151151
this.top = {}
152152
this.left = {}
153+
this.leftTop = {}
153154
this.layout = reactive(storageObject(`llms.layout`))
154155
this.prefs = reactive(storageObject(ai.prefsKey))
155156
this._onRouterBeforeEach = []
@@ -186,12 +187,33 @@ export class AppContext {
186187
this[name] = global
187188
})
188189
}
190+
getColorScheme() {
191+
return document.documentElement.classList.contains('dark') ? 'dark' : 'light'
192+
}
189193
getPrefs() {
190194
return this.prefs
191195
}
192196
setPrefs(o) {
193197
storageObject(this.ai.prefsKey, Object.assign(this.prefs, o))
194198
}
199+
_validateComponents(componentMap) {
200+
Object.entries(componentMap).forEach(([id, def]) => {
201+
if (!def.component) {
202+
console.error(`Component Definition ${id} is missing component property`)
203+
}
204+
def.id = id
205+
if (!def.name) {
206+
def.name = humanize(id)
207+
}
208+
if (typeof def.isActive != 'function') {
209+
def.isActive = () => false
210+
}
211+
})
212+
return componentMap
213+
}
214+
setLeftTop(componentMap) {
215+
Object.assign(this.leftTop, this._validateComponents(componentMap))
216+
}
195217
_validateIcons(icons) {
196218
Object.entries(icons).forEach(([id, icon]) => {
197219
if (!icon.component) {

llms/ui/modules/chat/ChatBody.mjs

Lines changed: 22 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -876,6 +876,22 @@ export const ToolCall = {
876876
}
877877
}
878878
}
879+
880+
export const UserAvatar = {
881+
template: `
882+
<img class="size-8 rounded-full" :src="'/avatar/user?mode=' + $ctx.getColorScheme()" />
883+
`
884+
}
885+
886+
export const AgentAvatar = {
887+
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+
}
893+
}
894+
879895
export const ChatBody = {
880896
template: `
881897
<div class="flex flex-col h-full">
@@ -913,19 +929,8 @@ export const ChatBody = {
913929
>
914930
<!-- Avatar outside the bubble -->
915931
<div class="flex-shrink-0 flex flex-col justify-center">
916-
<div class="w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium"
917-
:class="message.role === 'user'
918-
? 'bg-blue-100 dark:bg-blue-900 text-gray-900 dark:text-gray-100 border border-blue-200 dark:border-blue-700'
919-
: message.role === 'tool'
920-
? 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 border border-purple-200 dark:border-purple-800'
921-
: 'bg-gray-600 dark:bg-gray-500 text-white'"
922-
>
923-
<span v-if="message.role === 'user'">U</span>
924-
<svg v-else-if="message.role === 'tool'" class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
925-
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"></path>
926-
</svg>
927-
<span v-else>AI</span>
928-
</div>
932+
<UserAvatar v-if="message.role === 'user'" />
933+
<AgentAvatar v-else :role="message.role" />
929934
930935
<!-- Delete button (shown on hover) -->
931936
<button type="button" @click.stop="$threads.deleteMessageFromThread(currentThread.id, message.timestamp)"
@@ -1056,9 +1061,10 @@ export const ChatBody = {
10561061
<div v-if="$threads.watchingThread" class="flex items-start space-x-3 group">
10571062
<!-- Avatar outside the bubble -->
10581063
<div class="flex-shrink-0">
1059-
<div class="w-8 h-8 rounded-full bg-gray-600 dark:bg-gray-500 text-white flex items-center justify-center text-sm font-medium">
1060-
AI
1061-
</div>
1064+
<svg class="size-8" viewBox="0 0 32 32" fill="none">
1065+
<circle cx="16" cy="16" r="15" class="fill-gray-600 dark:fill-gray-500" stroke="none"/>
1066+
<path fill="none" stroke="white" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 20v-8a2.667 2.667 0 1 1 5.333 0v8m-5.333-4h5.333m5.334-6.667v10.667" transform="translate(2.667, 1.5)"/>
1067+
</svg>
10621068
</div>
10631069
10641070
<!-- Loading bubble -->
@@ -1363,7 +1369,6 @@ export const ChatBody = {
13631369
hasAttachments,
13641370
resolveUrl,
13651371
getMessageUsage,
1366-
getToolOutput,
13671372
isToolLinked,
13681373
tryParseJson,
13691374
hasJsonStructure,

llms/ui/modules/chat/index.mjs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import { ref, watch, computed, nextTick, inject, onMounted, onUnmounted } from 'vue'
22
import { $$, createElement, lastRightPart, ApiResult, createErrorStatus } from "@servicestack/client"
33
import SettingsDialog, { useSettings } from './SettingsDialog.mjs'
4-
import { ChatBody, ErrorBubble, LightboxImage, TypeText, TypeImage, TypeAudio, TypeFile, ViewType, ViewTypes, ViewToolTypes, TextViewer, ToolArguments, ToolOutput, MessageUsage, MessageReasoning, CompactThreadButton } from './ChatBody.mjs'
4+
import {
5+
ChatBody, ErrorBubble, LightboxImage, TypeText, TypeImage, TypeAudio, TypeFile, ViewType, ViewTypes,
6+
ViewToolTypes, TextViewer, ToolCall, ToolArguments, ToolOutput, MessageUsage, MessageReasoning,
7+
CompactThreadButton, UserAvatar, AgentAvatar
8+
} from './ChatBody.mjs'
59
import { AppContext } from '../../ctx.mjs'
610

711
const imageExts = 'png,webp,jpg,jpeg,gif,bmp,svg,tiff,ico'.split(',')
@@ -1121,6 +1125,8 @@ export default {
11211125
Home,
11221126
ThreadHeader,
11231127
ThreadFooter,
1128+
UserAvatar,
1129+
AgentAvatar,
11241130
})
11251131
ctx.setGlobals({
11261132
chat: useChatPrompt(ctx)

0 commit comments

Comments
 (0)