[codex] add customizable profile avatars (#870)

* add customizable profile avatars

* keep profile avatar visible when sidebar collapses

* simplify collapsed profile avatar styling

* force managed gateway startup in docker

* limit gateway autostart to active profile

* restore all profile gateway autostart

* fix managed gateway runtime detection
This commit is contained in:
ekko
2026-05-20 14:15:01 +08:00
committed by GitHub
parent 663afb61ff
commit c90eba226d
27 changed files with 892 additions and 94 deletions
@@ -1,14 +1,15 @@
<script setup lang="ts">
import { ref, computed, nextTick, watch } from 'vue'
import { ref, computed, nextTick, watch, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useMessage, NInput, NButton, NSpace, NSelect, NPopover, NPopconfirm, NInputNumber } from 'naive-ui'
import multiavatar from '@multiavatar/multiavatar'
import { useGroupChatStore } from '@/stores/hermes/group-chat'
import { useProfilesStore } from '@/stores/hermes/profiles'
import { updateRoomConfig, forceCompress } from '@/api/hermes/group-chat'
import GroupMessageList from './GroupMessageList.vue'
import GroupChatInput from './GroupChatInput.vue'
import ProfileAvatar from '@/components/hermes/profiles/ProfileAvatar.vue'
import type { Attachment } from '@/stores/hermes/chat'
import type { RoomAgent } from '@/api/hermes/group-chat'
const { t } = useI18n()
const message = useMessage()
@@ -33,13 +34,13 @@ const profileOptions = computed(() =>
profilesStore.profiles.map(p => ({ label: p.name, value: p.name }))
)
const avatarCache = new Map<string, string>()
function profileAvatarFor(profileName?: string) {
if (!profileName) return null
return profilesStore.profiles.find(profile => profile.name === profileName)?.avatar || null
}
function agentAvatarUrl(name: string): string {
if (avatarCache.has(name)) return avatarCache.get(name)!
const uri = multiavatar(name)
avatarCache.set(name, uri)
return uri
function agentAvatarName(agent: RoomAgent): string {
return agent.profile || agent.name || agent.agentId
}
const hasRoom = computed(() => !!store.currentRoomId)
@@ -146,6 +147,12 @@ async function handleAddAgent() {
showAddAgentModal.value = true
}
onMounted(() => {
if (profilesStore.profiles.length === 0) {
void profilesStore.fetchProfiles()
}
})
async function confirmAddAgent() {
if (!selectedProfile.value || !store.currentRoomId) return
try {
@@ -311,7 +318,7 @@ watch(() => store.sortedMessages.length, async () => {
<div class="avatar-stack-inner">
<!-- User avatar first -->
<span class="avatar-stack-item" :style="{ zIndex: store.agents.length + 1 }">
<span class="agent-avatar" v-html="agentAvatarUrl(store.userName || store.userId)" />
<ProfileAvatar class="agent-avatar" :name="store.userName || store.userId" :size="28" />
</span>
<span
v-for="(agent, index) in store.agents.slice(-4)"
@@ -319,14 +326,14 @@ watch(() => store.sortedMessages.length, async () => {
class="avatar-stack-item"
:style="{ zIndex: store.agents.length - index }"
>
<span class="agent-avatar" v-html="agentAvatarUrl(agent.name)" />
<ProfileAvatar class="agent-avatar" :name="agentAvatarName(agent)" :avatar="profileAvatarFor(agent.profile)" :size="28" />
</span>
<span v-if="store.agents.length > 4" class="avatar-stack-more">+{{ store.agents.length - 4 }}</span>
</div>
</template>
<div class="agent-popover">
<div class="agent-popover-item" style="margin-bottom: 8px; padding-bottom: 8px; border-bottom: 1px solid var(--n-border-color, #efeff5);">
<span class="agent-avatar" v-html="agentAvatarUrl(store.userName || store.userId)" />
<ProfileAvatar class="agent-avatar" :name="store.userName || store.userId" :size="28" />
<div class="agent-popover-info">
<span class="agent-popover-name">{{ store.userName || 'You' }}</span>
<span class="agent-popover-profile">{{ t('groupChat.you') }}</span>
@@ -334,7 +341,7 @@ watch(() => store.sortedMessages.length, async () => {
</div>
<div class="agent-popover-title">{{ t('groupChat.agents') }} ({{ store.agents.length }})</div>
<div v-for="agent in store.agents" :key="agent.id" class="agent-popover-item">
<span class="agent-avatar" v-html="agentAvatarUrl(agent.name)" />
<ProfileAvatar class="agent-avatar" :name="agentAvatarName(agent)" :avatar="profileAvatarFor(agent.profile)" :size="28" />
<div class="agent-popover-info">
<span class="agent-popover-name">{{ agent.name }}</span>
<span class="agent-popover-profile">{{ agent.profile }}</span>
@@ -348,7 +355,7 @@ watch(() => store.sortedMessages.length, async () => {
<!-- Only user avatar, no agents -->
<div v-else-if="store.userName" class="avatar-stack-inner">
<span class="avatar-stack-item">
<span class="agent-avatar" v-html="agentAvatarUrl(store.userName || store.userId)" />
<ProfileAvatar class="agent-avatar" :name="store.userName || store.userId" :size="28" />
</span>
</div>
<button class="icon-btn" :title="t('groupChat.addAgent')" @click="handleAddAgent">
@@ -2,8 +2,9 @@
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useMessage } from 'naive-ui'
import multiavatar from '@multiavatar/multiavatar'
import MarkdownRenderer from '../chat/MarkdownRenderer.vue'
import ProfileAvatar from '@/components/hermes/profiles/ProfileAvatar.vue'
import { useProfilesStore } from '@/stores/hermes/profiles'
import {
copyTextToClipboard,
handleCodeBlockCopyClick,
@@ -32,6 +33,7 @@ const props = defineProps<{
const { t } = useI18n()
const toast = useMessage()
const profilesStore = useProfilesStore()
const speech = useGlobalSpeech()
const voiceSettings = useVoiceSettings()
const previewUrl = ref<string | null>(null)
@@ -52,9 +54,8 @@ const timeStr = computed(() => {
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
})
const avatarSvg = computed(() => {
return multiavatar(props.message.senderName || props.message.senderId)
})
const avatarProfileName = computed(() => agentInfo.value?.profile || props.message.senderName || props.message.senderId)
const avatarProfile = computed(() => profilesStore.profiles.find(profile => profile.name === agentInfo.value?.profile))
const mentionNames = computed(() => ['all', ...props.agents.map(a => a.name).filter(Boolean)])
const parsedThinking = computed(() => parseThinking(props.message.content || '', { streaming: !!props.message.isStreaming }))
@@ -384,7 +385,7 @@ onBeforeUnmount(() => {
<template>
<div v-if="isToolMessage" class="group-message tool-message">
<div class="avatar">
<span v-html="avatarSvg" />
<ProfileAvatar :name="avatarProfileName" :avatar="avatarProfile?.avatar" :size="36" />
</div>
<div class="msg-body">
@@ -430,7 +431,7 @@ onBeforeUnmount(() => {
<div v-else class="group-message" :class="{ agent: isAgent, self: isSelf }">
<!-- Avatar -->
<div class="avatar">
<span v-html="avatarSvg" />
<ProfileAvatar :name="avatarProfileName" :avatar="avatarProfile?.avatar" :size="36" />
</div>
<div class="msg-body">
@@ -674,11 +675,6 @@ onBeforeUnmount(() => {
margin-top: 2px;
overflow: hidden;
border-radius: 8px;
:deep(svg) {
width: 36px;
height: 36px;
}
}
.msg-body {