[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:
@@ -9,7 +9,9 @@ import { copyToClipboard } from "@/utils/clipboard";
|
||||
import MarkdownRenderer from "./MarkdownRenderer.vue";
|
||||
import { parseThinking, countThinkingChars } from "@/utils/thinking-parser";
|
||||
import { useChatStore } from "@/stores/hermes/chat";
|
||||
import { useProfilesStore } from "@/stores/hermes/profiles";
|
||||
import { useSettingsStore } from "@/stores/hermes/settings";
|
||||
import ProfileAvatar from "@/components/hermes/profiles/ProfileAvatar.vue";
|
||||
import {
|
||||
copyTextToClipboard,
|
||||
handleCodeBlockCopyClick,
|
||||
@@ -180,9 +182,12 @@ const toolExpanded = ref(false);
|
||||
const previewUrl = ref<string | null>(null);
|
||||
|
||||
const chatStore = useChatStore();
|
||||
const profilesStore = useProfilesStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
const speech = useGlobalSpeech();
|
||||
const voiceSettings = useVoiceSettings();
|
||||
const assistantProfileName = computed(() => chatStore.activeSession?.profile || profilesStore.activeProfileName || "default");
|
||||
const assistantProfileAvatar = computed(() => profilesStore.profiles.find(profile => profile.name === assistantProfileName.value)?.avatar);
|
||||
|
||||
// Copy entire bubble content
|
||||
const copyableContent = computed(() => {
|
||||
@@ -773,11 +778,12 @@ onBeforeUnmount(() => {
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="msg-body">
|
||||
<img
|
||||
<ProfileAvatar
|
||||
v-if="message.role === 'assistant'"
|
||||
src="/logo.png"
|
||||
alt="Hermes"
|
||||
class="msg-avatar"
|
||||
:name="assistantProfileName"
|
||||
:avatar="assistantProfileAvatar"
|
||||
:size="40"
|
||||
/>
|
||||
<div class="msg-content" :class="message.role">
|
||||
<div
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
import { computed, ref, onUnmounted } from 'vue'
|
||||
import { NPopconfirm, NCheckbox } from 'naive-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import multiavatar from '@multiavatar/multiavatar'
|
||||
import type { Session } from '@/stores/hermes/chat'
|
||||
import { useAppStore } from '@/stores/hermes/app'
|
||||
import { useProfilesStore } from '@/stores/hermes/profiles'
|
||||
import ProfileAvatar from '@/components/hermes/profiles/ProfileAvatar.vue'
|
||||
import { formatTimestampMs } from '@/shared/session-display'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
@@ -29,13 +30,14 @@ const emit = defineEmits<{
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
const profilesStore = useProfilesStore()
|
||||
const sessionModelName = computed(() =>
|
||||
props.session.model
|
||||
? appStore.displayModelName(props.session.model, props.session.provider)
|
||||
: '',
|
||||
)
|
||||
const profileName = computed(() => props.session.profile || 'default')
|
||||
const profileAvatar = computed(() => multiavatar(profileName.value))
|
||||
const profileAvatar = computed(() => profilesStore.profiles.find(profile => profile.name === profileName.value)?.avatar)
|
||||
|
||||
let longPressTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const longPressTriggered = ref(false)
|
||||
@@ -114,7 +116,7 @@ onUnmounted(() => {
|
||||
<span class="session-item-time">{{ formatTimestampMs(session.createdAt) }}</span>
|
||||
</span>
|
||||
<span v-if="props.showProfile" class="session-item-profile">
|
||||
<span class="session-item-profile-avatar" v-html="profileAvatar" />
|
||||
<ProfileAvatar class="session-item-profile-avatar" :name="profileName" :avatar="profileAvatar" :size="16" />
|
||||
<span class="session-item-profile-name">{{ profileName }}</span>
|
||||
</span>
|
||||
</div>
|
||||
@@ -139,18 +141,7 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.session-item-profile-avatar {
|
||||
display: inline-flex;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex: 0 0 16px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.session-item-profile-avatar :deep(svg) {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: block;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.session-item-profile-name {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import multiavatar from '@multiavatar/multiavatar'
|
||||
import type { ProfileAvatar } from '@/api/hermes/profiles'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
name: string
|
||||
avatar?: ProfileAvatar | null
|
||||
size?: number
|
||||
}>(), {
|
||||
size: 24,
|
||||
})
|
||||
|
||||
const fallbackSeed = computed(() => props.name || 'default')
|
||||
const generatedSvg = computed(() => multiavatar(props.avatar?.seed || fallbackSeed.value))
|
||||
const style = computed(() => ({
|
||||
width: `${props.size}px`,
|
||||
height: `${props.size}px`,
|
||||
flexBasis: `${props.size}px`,
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="profile-avatar-view" :style="style">
|
||||
<img
|
||||
v-if="avatar?.type === 'image' && avatar.dataUrl"
|
||||
class="profile-avatar-image"
|
||||
:src="avatar.dataUrl"
|
||||
alt=""
|
||||
draggable="false"
|
||||
>
|
||||
<span v-else class="profile-avatar-svg" v-html="generatedSvg" />
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.profile-avatar-view {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.profile-avatar-image,
|
||||
.profile-avatar-svg,
|
||||
.profile-avatar-svg :deep(svg) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.profile-avatar-image {
|
||||
object-fit: cover;
|
||||
}
|
||||
</style>
|
||||
@@ -4,6 +4,7 @@ import { NButton, NTag, NSpin, useMessage, useDialog } from 'naive-ui'
|
||||
import type { HermesProfile, HermesProfileDetail } from '@/api/hermes/profiles'
|
||||
import { useProfilesStore } from '@/stores/hermes/profiles'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import ProfileAvatar from './ProfileAvatar.vue'
|
||||
|
||||
const props = defineProps<{ profile: HermesProfile }>()
|
||||
const emit = defineEmits<{}>()
|
||||
@@ -86,7 +87,10 @@ async function handleExport() {
|
||||
<template>
|
||||
<div class="profile-card" :class="{ active: profile.active }">
|
||||
<div class="card-header">
|
||||
<h3 class="profile-name">{{ profile.name }}</h3>
|
||||
<div class="profile-title">
|
||||
<ProfileAvatar :name="profile.name" :avatar="profile.avatar" :size="28" />
|
||||
<h3 class="profile-name">{{ profile.name }}</h3>
|
||||
</div>
|
||||
<NTag v-if="profile.active" size="tiny" type="success" :bordered="false">
|
||||
{{ t('profiles.active') }}
|
||||
</NTag>
|
||||
@@ -188,9 +192,17 @@ async function handleExport() {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.profile-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.profile-name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
@@ -198,7 +210,8 @@ async function handleExport() {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 70%;
|
||||
max-width: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
|
||||
Reference in New Issue
Block a user