[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:
@@ -33,6 +33,7 @@ RUN npm run build && npm prune --omit=dev
|
||||
ENV NODE_ENV=production
|
||||
ENV HOME=/home/agent
|
||||
ENV HERMES_HOME=/home/agent/.hermes
|
||||
ENV HERMES_WEB_UI_MANAGED_GATEWAY=1
|
||||
ENV PATH=/opt/hermes/.venv/bin:$PATH
|
||||
|
||||
EXPOSE 6060
|
||||
|
||||
@@ -15,6 +15,7 @@ services:
|
||||
- PORT=${PORT:-6060}
|
||||
- HERMES_HOME=/home/agent/.hermes
|
||||
- HERMES_BIN=/opt/hermes/.venv/bin/hermes
|
||||
- HERMES_WEB_UI_MANAGED_GATEWAY=1
|
||||
- HERMES_WEB_UI_XAI_CALLBACK_BIND_HOST=0.0.0.0
|
||||
- PATH=/opt/hermes/.venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||
- AUTH_DISABLED=${AUTH_DISABLED:-false}
|
||||
|
||||
@@ -6,6 +6,7 @@ export interface HermesProfile {
|
||||
model: string
|
||||
gatewayStatus?: string
|
||||
alias: string
|
||||
avatar?: ProfileAvatar | null
|
||||
}
|
||||
|
||||
export interface HermesProfileDetail {
|
||||
@@ -16,6 +17,14 @@ export interface HermesProfileDetail {
|
||||
skills: number
|
||||
hasEnv: boolean
|
||||
hasSoulMd: boolean
|
||||
avatar?: ProfileAvatar | null
|
||||
}
|
||||
|
||||
export interface ProfileAvatar {
|
||||
type: 'generated' | 'image'
|
||||
seed?: string
|
||||
dataUrl?: string
|
||||
updatedAt?: number
|
||||
}
|
||||
|
||||
export interface ProfileRuntimeStatus {
|
||||
@@ -62,6 +71,18 @@ export async function fetchProfileRuntimeStatuses(): Promise<ProfileRuntimeStatu
|
||||
return res.profiles
|
||||
}
|
||||
|
||||
export async function updateProfileAvatar(name: string, avatar: ProfileAvatar): Promise<ProfileAvatar> {
|
||||
const res = await request<{ avatar: ProfileAvatar }>(`/api/hermes/profiles/${encodeURIComponent(name)}/avatar`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(avatar),
|
||||
})
|
||||
return res.avatar
|
||||
}
|
||||
|
||||
export async function deleteProfileAvatar(name: string): Promise<void> {
|
||||
await request(`/api/hermes/profiles/${encodeURIComponent(name)}/avatar`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export async function restartProfileGateway(name: string): Promise<ProfileRuntimeStatus['gateway']> {
|
||||
const res = await request<{ success: boolean; gateway: ProfileRuntimeStatus['gateway'] }>(
|
||||
`/api/hermes/profiles/${encodeURIComponent(name)}/gateway/restart`,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -700,12 +700,46 @@ function openChangelog() {
|
||||
}
|
||||
}
|
||||
|
||||
// Hide selectors and footer text, keep theme switch
|
||||
:deep(.profile-selector),
|
||||
// Hide model selector in icon-rail mode, but keep the active profile avatar
|
||||
// visible as the profile manager entry point.
|
||||
:deep(.model-selector) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:deep(.profile-selector) {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 8px 0;
|
||||
margin: 0 0 6px;
|
||||
border-top: 1px solid $border-color;
|
||||
}
|
||||
|
||||
:deep(.profile-selector .selector-label),
|
||||
:deep(.profile-selector .profile-name) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:deep(.profile-selector .profile-display) {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
gap: 0;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
:deep(.profile-selector .profile-display:hover) {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
:deep(.profile-selector .profile-avatar) {
|
||||
width: 28px !important;
|
||||
height: 28px !important;
|
||||
flex-basis: 28px !important;
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
.logout-item {
|
||||
margin: 0;
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { NButton, NModal, NSpin, useMessage } from 'naive-ui'
|
||||
import multiavatar from '@multiavatar/multiavatar'
|
||||
import { useProfilesStore } from '@/stores/hermes/profiles'
|
||||
import {
|
||||
fetchProfileRuntimeStatuses,
|
||||
restartProfileGateway,
|
||||
restartProfileRuntime,
|
||||
type HermesProfile,
|
||||
type ProfileAvatar,
|
||||
type ProfileRuntimeStatus,
|
||||
} from '@/api/hermes/profiles'
|
||||
import ProfileAvatarView from '@/components/hermes/profiles/ProfileAvatar.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -17,19 +19,19 @@ const profilesStore = useProfilesStore()
|
||||
|
||||
const activeName = computed(() => profilesStore.activeProfileName ?? '')
|
||||
const displayName = computed(() => activeName.value || 'default')
|
||||
const avatarSvg = computed(() => multiavatar(displayName.value))
|
||||
const activeProfile = computed(() => profilesStore.profiles.find(profile => profile.name === displayName.value))
|
||||
const runtimeStatuses = ref<ProfileRuntimeStatus[]>([])
|
||||
const runtimeLoading = ref(false)
|
||||
const showProfileModal = ref(false)
|
||||
const showAvatarModal = ref(false)
|
||||
const editingProfile = ref<HermesProfile | null>(null)
|
||||
const avatarSaving = ref(false)
|
||||
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||
const gatewayRestarting = ref<Record<string, boolean>>({})
|
||||
const profileRestarting = ref<Record<string, boolean>>({})
|
||||
const profileSwitching = ref<Record<string, boolean>>({})
|
||||
const statusByProfile = computed(() => new Map(runtimeStatuses.value.map(status => [status.profile, status])))
|
||||
|
||||
function avatarFor(name: string) {
|
||||
return multiavatar(name || 'default')
|
||||
}
|
||||
|
||||
async function loadRuntimeStatuses() {
|
||||
runtimeLoading.value = true
|
||||
try {
|
||||
@@ -46,6 +48,73 @@ function openProfileModal() {
|
||||
void loadRuntimeStatuses()
|
||||
}
|
||||
|
||||
function openAvatarModal(profile: HermesProfile) {
|
||||
editingProfile.value = profile
|
||||
showAvatarModal.value = true
|
||||
}
|
||||
|
||||
function randomSeed() {
|
||||
return `profile-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
|
||||
}
|
||||
|
||||
async function saveAvatar(avatar: ProfileAvatar) {
|
||||
if (!editingProfile.value) return
|
||||
avatarSaving.value = true
|
||||
try {
|
||||
await profilesStore.updateAvatar(editingProfile.value.name, avatar)
|
||||
message.success(t('profiles.avatar.saveSuccess'))
|
||||
showAvatarModal.value = false
|
||||
} catch (err: any) {
|
||||
message.error(err?.message || t('profiles.avatar.saveFailed'))
|
||||
} finally {
|
||||
avatarSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRandomAvatar() {
|
||||
await saveAvatar({ type: 'generated', seed: randomSeed() })
|
||||
}
|
||||
|
||||
async function handleResetAvatar() {
|
||||
if (!editingProfile.value) return
|
||||
avatarSaving.value = true
|
||||
try {
|
||||
await profilesStore.deleteAvatar(editingProfile.value.name)
|
||||
message.success(t('profiles.avatar.resetSuccess'))
|
||||
showAvatarModal.value = false
|
||||
} catch (err: any) {
|
||||
message.error(err?.message || t('profiles.avatar.resetFailed'))
|
||||
} finally {
|
||||
avatarSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function triggerAvatarUpload() {
|
||||
fileInputRef.value?.click()
|
||||
}
|
||||
|
||||
async function handleAvatarFileChange(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
input.value = ''
|
||||
if (!file) return
|
||||
if (!['image/png', 'image/jpeg', 'image/webp'].includes(file.type)) {
|
||||
message.warning(t('profiles.avatar.invalidType'))
|
||||
return
|
||||
}
|
||||
if (file.size > 1024 * 1024) {
|
||||
message.warning(t('profiles.avatar.tooLarge'))
|
||||
return
|
||||
}
|
||||
const dataUrl = await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => resolve(String(reader.result || ''))
|
||||
reader.onerror = () => reject(reader.error || new Error('Failed to read file'))
|
||||
reader.readAsDataURL(file)
|
||||
})
|
||||
await saveAvatar({ type: 'image', dataUrl })
|
||||
}
|
||||
|
||||
function gatewayStatusText(running?: boolean) {
|
||||
return running ? t('profiles.runtime.running') : t('profiles.runtime.stopped')
|
||||
}
|
||||
@@ -113,7 +182,7 @@ onMounted(() => {
|
||||
<div class="profile-selector">
|
||||
<div class="selector-label">{{ t('sidebar.profiles') }}</div>
|
||||
<div class="profile-display" data-testid="profile-selector-select" @click="openProfileModal">
|
||||
<span class="profile-avatar" v-html="avatarSvg" />
|
||||
<ProfileAvatarView class="profile-avatar" :name="displayName" :avatar="activeProfile?.avatar" :size="24" />
|
||||
<span class="profile-name">{{ displayName }}</span>
|
||||
</div>
|
||||
|
||||
@@ -142,7 +211,7 @@ onMounted(() => {
|
||||
:class="{ active: profile.name === displayName }"
|
||||
>
|
||||
<div class="profile-runtime-main">
|
||||
<span class="profile-runtime-avatar" v-html="avatarFor(profile.name)" />
|
||||
<ProfileAvatarView class="profile-runtime-avatar" :name="profile.name" :avatar="profile.avatar" :size="34" />
|
||||
<div class="profile-runtime-info">
|
||||
<div class="profile-runtime-name-row">
|
||||
<span class="profile-runtime-name">{{ profile.name }}</span>
|
||||
@@ -173,6 +242,13 @@ onMounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
<div class="profile-runtime-actions">
|
||||
<NButton
|
||||
size="small"
|
||||
type="primary"
|
||||
@click="openAvatarModal(profile)"
|
||||
>
|
||||
{{ t('profiles.avatar.customize') }}
|
||||
</NButton>
|
||||
<NButton
|
||||
size="small"
|
||||
type="primary"
|
||||
@@ -203,6 +279,40 @@ onMounted(() => {
|
||||
</div>
|
||||
</NSpin>
|
||||
</NModal>
|
||||
|
||||
<NModal
|
||||
v-model:show="showAvatarModal"
|
||||
preset="card"
|
||||
:title="t('profiles.avatar.title')"
|
||||
:bordered="false"
|
||||
:style="{ width: '420px', maxWidth: 'calc(100vw - 32px)' }"
|
||||
>
|
||||
<div v-if="editingProfile" class="avatar-editor">
|
||||
<ProfileAvatarView :name="editingProfile.name" :avatar="editingProfile.avatar" :size="72" />
|
||||
<div class="avatar-editor-meta">
|
||||
<div class="avatar-editor-name">{{ editingProfile.name }}</div>
|
||||
<div class="avatar-editor-hint">{{ t('profiles.avatar.hint') }}</div>
|
||||
</div>
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
class="avatar-file-input"
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
@change="handleAvatarFileChange"
|
||||
>
|
||||
<div class="avatar-editor-actions">
|
||||
<NButton type="primary" :loading="avatarSaving" @click="triggerAvatarUpload">
|
||||
{{ t('profiles.avatar.upload') }}
|
||||
</NButton>
|
||||
<NButton type="primary" :loading="avatarSaving" @click="handleRandomAvatar">
|
||||
{{ t('profiles.avatar.random') }}
|
||||
</NButton>
|
||||
<NButton :loading="avatarSaving" @click="handleResetAvatar">
|
||||
{{ t('profiles.avatar.reset') }}
|
||||
</NButton>
|
||||
</div>
|
||||
</div>
|
||||
</NModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -237,18 +347,7 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.profile-avatar {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
flex: 0 0 auto;
|
||||
background: $bg-card;
|
||||
|
||||
:deep(svg) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.profile-name {
|
||||
@@ -351,18 +450,7 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.profile-runtime-avatar {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
flex: 0 0 auto;
|
||||
background: $bg-secondary;
|
||||
|
||||
:deep(svg) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.profile-runtime-info {
|
||||
@@ -401,9 +489,9 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.profile-runtime-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(88px, max-content));
|
||||
justify-content: end;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
|
||||
:deep(.n-button) {
|
||||
@@ -450,4 +538,70 @@ onMounted(() => {
|
||||
line-height: 1.4;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.avatar-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.avatar-editor-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.avatar-editor-name {
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: $text-primary;
|
||||
}
|
||||
|
||||
.avatar-editor-hint {
|
||||
font-size: 12px;
|
||||
color: $text-muted;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.avatar-file-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.avatar-editor-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.profile-runtime-actions {
|
||||
justify-content: flex-start;
|
||||
gap: 5px;
|
||||
|
||||
:deep(.n-button) {
|
||||
min-width: 0;
|
||||
--n-height: 26px !important;
|
||||
--n-font-size: 12px !important;
|
||||
--n-padding: 0 8px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.avatar-editor-actions {
|
||||
gap: 6px;
|
||||
|
||||
:deep(.n-button) {
|
||||
--n-height: 28px !important;
|
||||
--n-font-size: 12px !important;
|
||||
--n-padding: 0 9px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -528,6 +528,20 @@ jobTriggered: 'Job ausgelost',
|
||||
hasEnv: 'Hat .env',
|
||||
hasSoulMd: 'Hat soul.md',
|
||||
noProfiles: 'Keine Profile gefunden. Erstellen Sie eines, um zu beginnen.',
|
||||
avatar: {
|
||||
title: 'Custom Avatar',
|
||||
customize: 'Avatar',
|
||||
upload: 'Upload Image',
|
||||
random: 'Randomize',
|
||||
reset: 'Use Default',
|
||||
hint: 'PNG, JPEG, or WebP. Max 1MB.',
|
||||
invalidType: 'Please choose a PNG, JPEG, or WebP image',
|
||||
tooLarge: 'Avatar image must be 1MB or smaller',
|
||||
saveSuccess: 'Avatar saved',
|
||||
saveFailed: 'Failed to save avatar',
|
||||
resetSuccess: 'Default avatar restored',
|
||||
resetFailed: 'Failed to restore default avatar',
|
||||
},
|
||||
},
|
||||
|
||||
// Logs
|
||||
|
||||
@@ -695,6 +695,20 @@ export default {
|
||||
hasEnv: 'Has .env',
|
||||
hasSoulMd: 'Has soul.md',
|
||||
noProfiles: 'No profiles found. Create one to get started.',
|
||||
avatar: {
|
||||
title: 'Custom Avatar',
|
||||
customize: 'Avatar',
|
||||
upload: 'Upload Image',
|
||||
random: 'Randomize',
|
||||
reset: 'Use Default',
|
||||
hint: 'PNG, JPEG, or WebP. Max 1MB.',
|
||||
invalidType: 'Please choose a PNG, JPEG, or WebP image',
|
||||
tooLarge: 'Avatar image must be 1MB or smaller',
|
||||
saveSuccess: 'Avatar saved',
|
||||
saveFailed: 'Failed to save avatar',
|
||||
resetSuccess: 'Default avatar restored',
|
||||
resetFailed: 'Failed to restore default avatar',
|
||||
},
|
||||
runtime: {
|
||||
activeProfile: 'Active: {name}',
|
||||
bridgeWorker: 'Bridge worker',
|
||||
|
||||
@@ -528,6 +528,20 @@ jobTriggered: 'Job ejecutado',
|
||||
hasEnv: 'Tiene .env',
|
||||
hasSoulMd: 'Tiene soul.md',
|
||||
noProfiles: 'No se encontraron perfiles. Crea uno para comenzar.',
|
||||
avatar: {
|
||||
title: 'Custom Avatar',
|
||||
customize: 'Avatar',
|
||||
upload: 'Upload Image',
|
||||
random: 'Randomize',
|
||||
reset: 'Use Default',
|
||||
hint: 'PNG, JPEG, or WebP. Max 1MB.',
|
||||
invalidType: 'Please choose a PNG, JPEG, or WebP image',
|
||||
tooLarge: 'Avatar image must be 1MB or smaller',
|
||||
saveSuccess: 'Avatar saved',
|
||||
saveFailed: 'Failed to save avatar',
|
||||
resetSuccess: 'Default avatar restored',
|
||||
resetFailed: 'Failed to restore default avatar',
|
||||
},
|
||||
},
|
||||
|
||||
// Logs
|
||||
|
||||
@@ -528,6 +528,20 @@ jobTriggered: 'Job declenche',
|
||||
hasEnv: 'A un .env',
|
||||
hasSoulMd: 'A un soul.md',
|
||||
noProfiles: 'Aucun profil trouve. Creez-en un pour commencer.',
|
||||
avatar: {
|
||||
title: 'Custom Avatar',
|
||||
customize: 'Avatar',
|
||||
upload: 'Upload Image',
|
||||
random: 'Randomize',
|
||||
reset: 'Use Default',
|
||||
hint: 'PNG, JPEG, or WebP. Max 1MB.',
|
||||
invalidType: 'Please choose a PNG, JPEG, or WebP image',
|
||||
tooLarge: 'Avatar image must be 1MB or smaller',
|
||||
saveSuccess: 'Avatar saved',
|
||||
saveFailed: 'Failed to save avatar',
|
||||
resetSuccess: 'Default avatar restored',
|
||||
resetFailed: 'Failed to restore default avatar',
|
||||
},
|
||||
},
|
||||
|
||||
// Logs
|
||||
|
||||
@@ -528,6 +528,20 @@ export default {
|
||||
hasEnv: '.env あり',
|
||||
hasSoulMd: 'soul.md あり',
|
||||
noProfiles: 'プロファイルがありません。作成して始めましょう。',
|
||||
avatar: {
|
||||
title: 'Custom Avatar',
|
||||
customize: 'Avatar',
|
||||
upload: 'Upload Image',
|
||||
random: 'Randomize',
|
||||
reset: 'Use Default',
|
||||
hint: 'PNG, JPEG, or WebP. Max 1MB.',
|
||||
invalidType: 'Please choose a PNG, JPEG, or WebP image',
|
||||
tooLarge: 'Avatar image must be 1MB or smaller',
|
||||
saveSuccess: 'Avatar saved',
|
||||
saveFailed: 'Failed to save avatar',
|
||||
resetSuccess: 'Default avatar restored',
|
||||
resetFailed: 'Failed to restore default avatar',
|
||||
},
|
||||
},
|
||||
|
||||
// ログ
|
||||
|
||||
@@ -528,6 +528,20 @@ export default {
|
||||
hasEnv: '.env 있음',
|
||||
hasSoulMd: 'soul.md 있음',
|
||||
noProfiles: '프로필이 없습니다. 새로 만들어 시작하세요.',
|
||||
avatar: {
|
||||
title: 'Custom Avatar',
|
||||
customize: 'Avatar',
|
||||
upload: 'Upload Image',
|
||||
random: 'Randomize',
|
||||
reset: 'Use Default',
|
||||
hint: 'PNG, JPEG, or WebP. Max 1MB.',
|
||||
invalidType: 'Please choose a PNG, JPEG, or WebP image',
|
||||
tooLarge: 'Avatar image must be 1MB or smaller',
|
||||
saveSuccess: 'Avatar saved',
|
||||
saveFailed: 'Failed to save avatar',
|
||||
resetSuccess: 'Default avatar restored',
|
||||
resetFailed: 'Failed to restore default avatar',
|
||||
},
|
||||
},
|
||||
|
||||
// 로그
|
||||
|
||||
@@ -528,6 +528,20 @@ jobTriggered: 'Job acionado',
|
||||
hasEnv: 'Tem .env',
|
||||
hasSoulMd: 'Tem soul.md',
|
||||
noProfiles: 'Nenhum perfil encontrado. Crie um para comecar.',
|
||||
avatar: {
|
||||
title: 'Custom Avatar',
|
||||
customize: 'Avatar',
|
||||
upload: 'Upload Image',
|
||||
random: 'Randomize',
|
||||
reset: 'Use Default',
|
||||
hint: 'PNG, JPEG, or WebP. Max 1MB.',
|
||||
invalidType: 'Please choose a PNG, JPEG, or WebP image',
|
||||
tooLarge: 'Avatar image must be 1MB or smaller',
|
||||
saveSuccess: 'Avatar saved',
|
||||
saveFailed: 'Failed to save avatar',
|
||||
resetSuccess: 'Default avatar restored',
|
||||
resetFailed: 'Failed to restore default avatar',
|
||||
},
|
||||
},
|
||||
|
||||
// Logs
|
||||
|
||||
@@ -675,6 +675,20 @@ export default {
|
||||
hasEnv: '有 .env',
|
||||
hasSoulMd: '有 soul.md',
|
||||
noProfiles: '目前無設定檔,建立一個開始吧。',
|
||||
avatar: {
|
||||
title: '自訂頭像',
|
||||
customize: '頭像',
|
||||
upload: '上傳圖片',
|
||||
random: '隨機產生',
|
||||
reset: '恢復預設',
|
||||
hint: '支援 PNG、JPEG、WebP,最大 1MB',
|
||||
invalidType: '請選擇 PNG、JPEG 或 WebP 圖片',
|
||||
tooLarge: '頭像圖片不能超過 1MB',
|
||||
saveSuccess: '頭像已儲存',
|
||||
saveFailed: '儲存頭像失敗',
|
||||
resetSuccess: '已恢復預設頭像',
|
||||
resetFailed: '恢復預設頭像失敗',
|
||||
},
|
||||
},
|
||||
|
||||
// 日誌
|
||||
|
||||
@@ -687,6 +687,20 @@ export default {
|
||||
hasEnv: '有 .env',
|
||||
hasSoulMd: '有 soul.md',
|
||||
noProfiles: '暂无配置,创建一个开始吧。',
|
||||
avatar: {
|
||||
title: '自定义头像',
|
||||
customize: '头像',
|
||||
upload: '上传图片',
|
||||
random: '随机生成',
|
||||
reset: '恢复默认',
|
||||
hint: '支持 PNG、JPEG、WebP,最大 1MB',
|
||||
invalidType: '请选择 PNG、JPEG 或 WebP 图片',
|
||||
tooLarge: '头像图片不能超过 1MB',
|
||||
saveSuccess: '头像已保存',
|
||||
saveFailed: '保存头像失败',
|
||||
resetSuccess: '已恢复默认头像',
|
||||
resetFailed: '恢复默认头像失败',
|
||||
},
|
||||
runtime: {
|
||||
activeProfile: '当前:{name}',
|
||||
bridgeWorker: '桥接状态',
|
||||
|
||||
@@ -44,6 +44,33 @@ export const useProfilesStore = defineStore('profiles', () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function updateAvatar(name: string, avatar: profilesApi.ProfileAvatar) {
|
||||
const saved = await profilesApi.updateProfileAvatar(name, avatar)
|
||||
profiles.value = profiles.value.map(profile => (
|
||||
profile.name === name ? { ...profile, avatar: saved } : profile
|
||||
))
|
||||
if (detailMap.value[name]) {
|
||||
detailMap.value[name] = { ...detailMap.value[name], avatar: saved }
|
||||
}
|
||||
if (activeProfile.value?.name === name) {
|
||||
activeProfile.value = { ...activeProfile.value, avatar: saved }
|
||||
}
|
||||
return saved
|
||||
}
|
||||
|
||||
async function deleteAvatar(name: string) {
|
||||
await profilesApi.deleteProfileAvatar(name)
|
||||
profiles.value = profiles.value.map(profile => (
|
||||
profile.name === name ? { ...profile, avatar: null } : profile
|
||||
))
|
||||
if (detailMap.value[name]) {
|
||||
detailMap.value[name] = { ...detailMap.value[name], avatar: null }
|
||||
}
|
||||
if (activeProfile.value?.name === name) {
|
||||
activeProfile.value = { ...activeProfile.value, avatar: null }
|
||||
}
|
||||
}
|
||||
|
||||
async function createProfile(name: string, clone?: boolean) {
|
||||
const res = await profilesApi.createProfile(name, clone)
|
||||
if (res.success) await fetchProfiles()
|
||||
@@ -146,6 +173,8 @@ export const useProfilesStore = defineStore('profiles', () => {
|
||||
switchProfile,
|
||||
exportProfile,
|
||||
importProfile,
|
||||
updateAvatar,
|
||||
deleteAvatar,
|
||||
clearAllSessionCaches,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { createReadStream, existsSync, readdirSync, rmSync, unlinkSync, writeFileSync } from 'fs'
|
||||
import { createReadStream, existsSync, readFileSync, readdirSync, renameSync, rmSync, unlinkSync, writeFileSync } from 'fs'
|
||||
import { mkdir, writeFile } from 'fs/promises'
|
||||
import { basename, join } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
import { getWebUiHome } from '../../config'
|
||||
import * as hermesCli from '../../services/hermes/hermes-cli'
|
||||
import { SessionDeleter } from '../../services/hermes/session-deleter'
|
||||
import { AgentBridgeClient } from '../../services/hermes/agent-bridge'
|
||||
@@ -17,6 +18,21 @@ import type { HermesProfile } from '../../services/hermes/hermes-cli'
|
||||
|
||||
const bridgeCleanupClient = () => new AgentBridgeClient({ connectRetryMs: 0, timeoutMs: 5000 })
|
||||
|
||||
interface ProfileAvatarMeta {
|
||||
type: 'generated' | 'image'
|
||||
seed?: string
|
||||
file?: string
|
||||
mime?: string
|
||||
updatedAt?: number
|
||||
}
|
||||
|
||||
interface ProfileAvatarResponse {
|
||||
type: 'generated' | 'image'
|
||||
seed?: string
|
||||
dataUrl?: string
|
||||
updatedAt?: number
|
||||
}
|
||||
|
||||
const RESERVED_PROFILE_NAMES = new Set([
|
||||
'hermes', 'default', 'test', 'tmp', 'root', 'sudo',
|
||||
])
|
||||
@@ -92,6 +108,78 @@ function filterVisibleProfiles(profiles: HermesProfile[]): HermesProfile[] {
|
||||
return profiles.filter(profile => !isForbiddenProfileName(profile.name))
|
||||
}
|
||||
|
||||
function profileMetadataRoot(): string {
|
||||
return join(getWebUiHome(), 'profile-metadata')
|
||||
}
|
||||
|
||||
function profileMetadataDir(name: string): string {
|
||||
const segment = Buffer.from(name || 'default', 'utf-8').toString('base64url')
|
||||
return join(profileMetadataRoot(), segment)
|
||||
}
|
||||
|
||||
function profileAvatarMetaPath(name: string): string {
|
||||
return join(profileMetadataDir(name), 'avatar.json')
|
||||
}
|
||||
|
||||
function profileAvatarImagePath(name: string, file = 'avatar.bin'): string {
|
||||
return join(profileMetadataDir(name), file)
|
||||
}
|
||||
|
||||
function readProfileAvatar(name: string): ProfileAvatarResponse | null {
|
||||
const metaPath = profileAvatarMetaPath(name)
|
||||
if (!existsSync(metaPath)) return null
|
||||
try {
|
||||
const meta = JSON.parse(readFileSync(metaPath, 'utf-8')) as ProfileAvatarMeta
|
||||
if (meta.type === 'generated') {
|
||||
return {
|
||||
type: 'generated',
|
||||
seed: typeof meta.seed === 'string' ? meta.seed : name,
|
||||
updatedAt: meta.updatedAt,
|
||||
}
|
||||
}
|
||||
if (meta.type === 'image' && meta.file && meta.mime) {
|
||||
const imagePath = profileAvatarImagePath(name, meta.file)
|
||||
if (!existsSync(imagePath)) return null
|
||||
const data = readFileSync(imagePath).toString('base64')
|
||||
return {
|
||||
type: 'image',
|
||||
dataUrl: `data:${meta.mime};base64,${data}`,
|
||||
updatedAt: meta.updatedAt,
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(err, '[profiles] failed to read avatar metadata for profile "%s"', name)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function attachProfileAvatars<T extends HermesProfile>(profiles: T[]): Array<T & { avatar: ProfileAvatarResponse | null }> {
|
||||
return profiles.map(profile => ({
|
||||
...profile,
|
||||
avatar: readProfileAvatar(profile.name),
|
||||
}))
|
||||
}
|
||||
|
||||
function parseAvatarDataUrl(dataUrl: string): { mime: string; buffer: Buffer } {
|
||||
const match = dataUrl.match(/^data:(image\/(?:png|jpeg|webp));base64,([a-zA-Z0-9+/=]+)$/)
|
||||
if (!match) throw new Error('Avatar image must be a PNG, JPEG, or WebP data URL')
|
||||
const buffer = Buffer.from(match[2], 'base64')
|
||||
if (buffer.length > 1024 * 1024) throw new Error('Avatar image must be 1MB or smaller')
|
||||
return { mime: match[1], buffer }
|
||||
}
|
||||
|
||||
function removeProfileMetadata(name: string): void {
|
||||
rmSync(profileMetadataDir(name), { recursive: true, force: true })
|
||||
}
|
||||
|
||||
function renameProfileMetadata(oldName: string, newName: string): void {
|
||||
const oldDir = profileMetadataDir(oldName)
|
||||
const newDir = profileMetadataDir(newName)
|
||||
if (!existsSync(oldDir) || oldDir === newDir) return
|
||||
rmSync(newDir, { recursive: true, force: true })
|
||||
renameSync(oldDir, newDir)
|
||||
}
|
||||
|
||||
async function useProfileWithFallback(name: string): Promise<string> {
|
||||
if (isForbiddenProfileName(name)) {
|
||||
throw new Error(`Profile name '${name}' is reserved and cannot be activated`)
|
||||
@@ -136,9 +224,22 @@ async function buildRuntimeStatus(profile: HermesProfile | string, bridgeState?:
|
||||
const bridge = bridgeState || await readBridgeWorkers()
|
||||
let gateway: { running: boolean; profile: string; error?: string }
|
||||
if (typeof profile !== 'string' && profile.gatewayStatus !== undefined) {
|
||||
gateway = {
|
||||
running: gatewayStatusLooksRunning(profile.gatewayStatus),
|
||||
profile: name,
|
||||
const profileListRunning = gatewayStatusLooksRunning(profile.gatewayStatus)
|
||||
if (profileListRunning) {
|
||||
gateway = {
|
||||
running: true,
|
||||
profile: name,
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
gateway = await getGatewayRuntimeStatusForProfile(name)
|
||||
} catch (err: any) {
|
||||
gateway = {
|
||||
running: false,
|
||||
profile: name,
|
||||
error: err?.message || 'Gateway status check failed',
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
@@ -198,7 +299,7 @@ export async function list(ctx: any) {
|
||||
p.active = (p.name === activeProfileName)
|
||||
})
|
||||
|
||||
ctx.body = { profiles }
|
||||
ctx.body = { profiles: attachProfileAvatars(profiles) }
|
||||
} catch (err: any) {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: err.message }
|
||||
@@ -268,13 +369,63 @@ export async function create(ctx: any) {
|
||||
export async function get(ctx: any) {
|
||||
try {
|
||||
const profile = await hermesCli.getProfile(ctx.params.name)
|
||||
ctx.body = { profile }
|
||||
ctx.body = { profile: { ...profile, avatar: readProfileAvatar(profile.name) } }
|
||||
} catch (err: any) {
|
||||
ctx.status = err.message.includes('not found') ? 404 : 500
|
||||
ctx.body = { error: err.message }
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateAvatar(ctx: any) {
|
||||
const name = String(ctx.params.name || '').trim() || 'default'
|
||||
if (isForbiddenProfileName(name)) {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: `Profile name '${name}' is reserved` }
|
||||
return
|
||||
}
|
||||
const body = ctx.request.body as { type?: string; seed?: string; dataUrl?: string }
|
||||
try {
|
||||
const dir = profileMetadataDir(name)
|
||||
await mkdir(dir, { recursive: true })
|
||||
const updatedAt = Date.now()
|
||||
|
||||
if (body.type === 'generated') {
|
||||
const seed = String(body.seed || name).trim() || name
|
||||
const meta: ProfileAvatarMeta = { type: 'generated', seed, updatedAt }
|
||||
rmSync(profileAvatarImagePath(name), { force: true })
|
||||
await writeFile(profileAvatarMetaPath(name), JSON.stringify(meta, null, 2) + '\n', { mode: 0o600 })
|
||||
ctx.body = { avatar: readProfileAvatar(name) }
|
||||
return
|
||||
}
|
||||
|
||||
if (body.type === 'image' && typeof body.dataUrl === 'string') {
|
||||
const { mime, buffer } = parseAvatarDataUrl(body.dataUrl)
|
||||
const meta: ProfileAvatarMeta = { type: 'image', file: 'avatar.bin', mime, updatedAt }
|
||||
await writeFile(profileAvatarImagePath(name), buffer, { mode: 0o600 })
|
||||
await writeFile(profileAvatarMetaPath(name), JSON.stringify(meta, null, 2) + '\n', { mode: 0o600 })
|
||||
ctx.body = { avatar: readProfileAvatar(name) }
|
||||
return
|
||||
}
|
||||
|
||||
ctx.status = 400
|
||||
ctx.body = { error: 'Invalid avatar payload' }
|
||||
} catch (err: any) {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: err.message }
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteAvatar(ctx: any) {
|
||||
const name = String(ctx.params.name || '').trim() || 'default'
|
||||
try {
|
||||
removeProfileMetadata(name)
|
||||
ctx.body = { success: true }
|
||||
} catch (err: any) {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: err.message }
|
||||
}
|
||||
}
|
||||
|
||||
export async function runtimeStatus(ctx: any) {
|
||||
const name = String(ctx.params.name || '').trim() || 'default'
|
||||
if (isForbiddenProfileName(name)) {
|
||||
@@ -374,8 +525,10 @@ export async function remove(ctx: any) {
|
||||
}
|
||||
const ok = await hermesCli.deleteProfile(name)
|
||||
if (ok) {
|
||||
removeProfileMetadata(name)
|
||||
ctx.body = { success: true }
|
||||
} else if (deleteForbiddenProfileFromDisk(name)) {
|
||||
removeProfileMetadata(name)
|
||||
ctx.body = { success: true, fallback: 'removed_reserved_profile_from_disk' }
|
||||
} else {
|
||||
ctx.status = 500
|
||||
@@ -397,6 +550,7 @@ export async function rename(ctx: any) {
|
||||
try {
|
||||
const ok = await hermesCli.renameProfile(ctx.params.name, new_name)
|
||||
if (ok) {
|
||||
renameProfileMetadata(ctx.params.name, new_name)
|
||||
ctx.body = { success: true }
|
||||
} else {
|
||||
ctx.status = 500
|
||||
|
||||
@@ -9,6 +9,8 @@ profileRoutes.get('/api/hermes/profiles/runtime-statuses', ctrl.runtimeStatuses)
|
||||
profileRoutes.get('/api/hermes/profiles/:name/runtime-status', ctrl.runtimeStatus)
|
||||
profileRoutes.post('/api/hermes/profiles/:name/restart', ctrl.restartProfileRuntime)
|
||||
profileRoutes.post('/api/hermes/profiles/:name/gateway/restart', ctrl.restartGatewayForProfile)
|
||||
profileRoutes.put('/api/hermes/profiles/:name/avatar', ctrl.updateAvatar)
|
||||
profileRoutes.delete('/api/hermes/profiles/:name/avatar', ctrl.deleteAvatar)
|
||||
profileRoutes.get('/api/hermes/profiles/:name', ctrl.get)
|
||||
profileRoutes.delete('/api/hermes/profiles/:name', ctrl.remove)
|
||||
profileRoutes.post('/api/hermes/profiles/:name/rename', ctrl.rename)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { execFile } from 'child_process'
|
||||
import { existsSync } from 'fs'
|
||||
import { existsSync, readFileSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { promisify } from 'util'
|
||||
import { stripLegacyApiServerGatewayConfig } from '../config-helpers'
|
||||
@@ -42,8 +42,22 @@ function isTermuxRuntime(): boolean {
|
||||
existsSync('/data/data/com.termux/files/usr')
|
||||
}
|
||||
|
||||
function requiresManagedGatewayRun(): boolean {
|
||||
return isDockerRuntime() || isTermuxRuntime() || process.platform === 'win32'
|
||||
function envFlagEnabled(name: string): boolean {
|
||||
const value = String(process.env[name] || '').trim().toLowerCase()
|
||||
return ['1', 'true', 'yes', 'on'].includes(value)
|
||||
}
|
||||
|
||||
export function shouldUseManagedGatewayRun(): boolean {
|
||||
return envFlagEnabled('HERMES_WEB_UI_MANAGED_GATEWAY') ||
|
||||
isDockerRuntime() ||
|
||||
isTermuxRuntime() ||
|
||||
process.platform === 'win32'
|
||||
}
|
||||
|
||||
export function shouldUseManagedGatewayRunForAutostart(): boolean {
|
||||
return envFlagEnabled('HERMES_WEB_UI_MANAGED_GATEWAY') ||
|
||||
isDockerRuntime() ||
|
||||
isTermuxRuntime()
|
||||
}
|
||||
|
||||
export function gatewayStatusLooksRunning(output: string): boolean {
|
||||
@@ -59,6 +73,42 @@ export function gatewayStatusLooksRuntimeLocked(output: string): boolean {
|
||||
|| text.includes('already held by another instance')
|
||||
}
|
||||
|
||||
function isProcessAlive(pid: number): boolean {
|
||||
if (!Number.isFinite(pid) || pid <= 0) return false
|
||||
try {
|
||||
process.kill(pid, 0)
|
||||
return true
|
||||
} catch (err: any) {
|
||||
return err?.code === 'EPERM'
|
||||
}
|
||||
}
|
||||
|
||||
function readJsonPid(path: string): number | null {
|
||||
if (!existsSync(path)) return null
|
||||
try {
|
||||
const data = JSON.parse(readFileSync(path, 'utf-8'))
|
||||
const pid = typeof data?.pid === 'number' ? data.pid : parseInt(String(data?.pid || ''), 10)
|
||||
return Number.isFinite(pid) && pid > 0 ? pid : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function gatewayStateLooksRunningForProfile(profileDir: string): boolean {
|
||||
const statePath = join(profileDir, 'gateway_state.json')
|
||||
if (existsSync(statePath)) {
|
||||
try {
|
||||
const data = JSON.parse(readFileSync(statePath, 'utf-8'))
|
||||
const state = String(data?.gateway_state || '').toLowerCase()
|
||||
const pid = typeof data?.pid === 'number' ? data.pid : parseInt(String(data?.pid || ''), 10)
|
||||
if ((state === 'running' || state === 'starting') && isProcessAlive(pid)) return true
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const pid = readJsonPid(join(profileDir, 'gateway.pid'))
|
||||
return pid !== null && isProcessAlive(pid)
|
||||
}
|
||||
|
||||
export function parseGatewayStatusesFromProfileListOutput(stdout: string): Map<string, string> {
|
||||
const statuses = new Map<string, string>()
|
||||
const normalized = stdout.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
|
||||
@@ -91,6 +141,8 @@ async function isGatewayRunningInProfileList(hermesBin: string, profile: string)
|
||||
}
|
||||
|
||||
export async function isGatewayRunningForProfile(hermesBin: string, profileDir: string): Promise<boolean> {
|
||||
if (gatewayStateLooksRunningForProfile(profileDir)) return true
|
||||
|
||||
try {
|
||||
const { stdout, stderr } = await execFileAsync(hermesBin, ['gateway', 'status'], {
|
||||
timeout: 10000,
|
||||
@@ -144,8 +196,13 @@ async function stopGatewayForProfile(hermesBin: string, profile: string, profile
|
||||
}
|
||||
}
|
||||
|
||||
export async function startGatewayForProfile(hermesBin: string, profile: string, profileDir: string): Promise<void> {
|
||||
if (requiresManagedGatewayRun()) {
|
||||
export async function startGatewayForProfile(
|
||||
hermesBin: string,
|
||||
profile: string,
|
||||
profileDir: string,
|
||||
opts: { managedRun?: boolean } = {},
|
||||
): Promise<void> {
|
||||
if (opts.managedRun ?? shouldUseManagedGatewayRun()) {
|
||||
const result = startGatewayRunManaged(hermesBin, { profileDir })
|
||||
logger.info(
|
||||
'[gateway-autostart] gateway started via background run profile=%s home=%s pid=%s',
|
||||
@@ -192,7 +249,7 @@ export async function restartGatewayForProfile(profile: string): Promise<{ runni
|
||||
await stopGatewayForProfile(hermesBin, profile, profileDir)
|
||||
|
||||
try {
|
||||
await startGatewayForProfile(hermesBin, profile, profileDir)
|
||||
await startGatewayForProfile(hermesBin, profile, profileDir, { managedRun: shouldUseManagedGatewayRun() })
|
||||
} catch (err) {
|
||||
logger.error(err, '[gateway-autostart] Hermes gateway restart failed profile=%s home=%s', profile, profileDir)
|
||||
throw err
|
||||
@@ -233,8 +290,8 @@ export async function ensureProfileGatewaysRunning(): Promise<void> {
|
||||
|
||||
const profileDir = getProfileDir(profile)
|
||||
const status = gatewayStatuses?.get(profile)
|
||||
const running = status !== undefined
|
||||
? gatewayStatusLooksRunning(status)
|
||||
const running = status !== undefined && gatewayStatusLooksRunning(status)
|
||||
? true
|
||||
: await isGatewayRunningForProfile(hermesBin, profileDir)
|
||||
if (running) {
|
||||
logger.info('[gateway-autostart] gateway already running profile=%s home=%s status=%s', profile, profileDir, status || 'status-check')
|
||||
@@ -242,7 +299,7 @@ export async function ensureProfileGatewaysRunning(): Promise<void> {
|
||||
}
|
||||
|
||||
await clearApiServerForProfile(profileDir)
|
||||
await startGatewayForProfile(hermesBin, profile, profileDir)
|
||||
await startGatewayForProfile(hermesBin, profile, profileDir, { managedRun: shouldUseManagedGatewayRunForAutostart() })
|
||||
const ready = await waitForGatewayRunning(hermesBin, profile, profileDir)
|
||||
if (!ready) {
|
||||
logger.warn('[gateway-autostart] gateway start completed but did not report running within timeout profile=%s home=%s', profile, profileDir)
|
||||
|
||||
@@ -11,6 +11,8 @@ const mockProfilesApi = vi.hoisted(() => ({
|
||||
switchProfile: vi.fn(),
|
||||
exportProfile: vi.fn(),
|
||||
importProfile: vi.fn(),
|
||||
updateProfileAvatar: vi.fn(),
|
||||
deleteProfileAvatar: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/hermes/profiles', () => mockProfilesApi)
|
||||
@@ -92,6 +94,54 @@ describe('Profiles Store', () => {
|
||||
expect(mockProfilesApi.fetchProfileDetail).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('updateAvatar updates profile, detail cache, and active profile', async () => {
|
||||
const savedAvatar = { type: 'image', dataUrl: 'data:image/png;base64,YQ==' }
|
||||
mockProfilesApi.updateProfileAvatar.mockResolvedValue(savedAvatar)
|
||||
|
||||
const store = useProfilesStore()
|
||||
store.profiles = [
|
||||
{ name: 'default', active: true, model: 'gpt-4', alias: '' },
|
||||
{ name: 'dev', active: false, model: 'gpt-4', alias: '' },
|
||||
]
|
||||
store.activeProfile = store.profiles[0]
|
||||
store.detailMap.default = { name: 'default', path: '/tmp/default', model: '', provider: '', skills: 0, hasEnv: false, hasSoulMd: false }
|
||||
|
||||
const result = await store.updateAvatar('default', { type: 'image', dataUrl: savedAvatar.dataUrl })
|
||||
|
||||
expect(result).toEqual(savedAvatar)
|
||||
expect(mockProfilesApi.updateProfileAvatar).toHaveBeenCalledWith('default', { type: 'image', dataUrl: savedAvatar.dataUrl })
|
||||
expect(store.profiles[0].avatar).toEqual(savedAvatar)
|
||||
expect(store.activeProfile?.avatar).toEqual(savedAvatar)
|
||||
expect(store.detailMap.default.avatar).toEqual(savedAvatar)
|
||||
})
|
||||
|
||||
it('deleteAvatar clears avatar state', async () => {
|
||||
mockProfilesApi.deleteProfileAvatar.mockResolvedValue(undefined)
|
||||
|
||||
const store = useProfilesStore()
|
||||
store.profiles = [
|
||||
{ name: 'default', active: true, model: 'gpt-4', alias: '', avatar: { type: 'generated', seed: 'old' } },
|
||||
]
|
||||
store.activeProfile = store.profiles[0]
|
||||
store.detailMap.default = {
|
||||
name: 'default',
|
||||
path: '/tmp/default',
|
||||
model: '',
|
||||
provider: '',
|
||||
skills: 0,
|
||||
hasEnv: false,
|
||||
hasSoulMd: false,
|
||||
avatar: { type: 'generated', seed: 'old' },
|
||||
}
|
||||
|
||||
await store.deleteAvatar('default')
|
||||
|
||||
expect(mockProfilesApi.deleteProfileAvatar).toHaveBeenCalledWith('default')
|
||||
expect(store.profiles[0].avatar).toBeNull()
|
||||
expect(store.activeProfile?.avatar).toBeNull()
|
||||
expect(store.detailMap.default.avatar).toBeNull()
|
||||
})
|
||||
|
||||
it('switchProfile sets switching state', async () => {
|
||||
mockProfilesApi.switchProfile.mockResolvedValue(true)
|
||||
mockProfilesApi.fetchProfiles.mockResolvedValue([])
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
import {
|
||||
gatewayStatusLooksRuntimeLocked,
|
||||
gatewayStatusLooksRunning,
|
||||
gatewayStateLooksRunningForProfile,
|
||||
parseGatewayStatusesFromProfileListOutput,
|
||||
shouldUseManagedGatewayRun,
|
||||
shouldUseManagedGatewayRunForAutostart,
|
||||
} from '../../packages/server/src/services/hermes/gateway-autostart'
|
||||
|
||||
describe('gateway autostart status parsing', () => {
|
||||
@@ -35,4 +41,30 @@ describe('gateway autostart status parsing', () => {
|
||||
expect(gatewayStatusLooksRunning('stopped')).toBe(false)
|
||||
expect(gatewayStatusLooksRunning('not running')).toBe(false)
|
||||
})
|
||||
|
||||
it('allows managed gateway mode to be forced by environment', () => {
|
||||
const previous = process.env.HERMES_WEB_UI_MANAGED_GATEWAY
|
||||
process.env.HERMES_WEB_UI_MANAGED_GATEWAY = '1'
|
||||
try {
|
||||
expect(shouldUseManagedGatewayRun()).toBe(true)
|
||||
expect(shouldUseManagedGatewayRunForAutostart()).toBe(true)
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.HERMES_WEB_UI_MANAGED_GATEWAY
|
||||
else process.env.HERMES_WEB_UI_MANAGED_GATEWAY = previous
|
||||
}
|
||||
})
|
||||
|
||||
it('detects managed gateway state files with a live pid', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'hermes-gateway-state-'))
|
||||
try {
|
||||
writeFileSync(
|
||||
join(dir, 'gateway_state.json'),
|
||||
JSON.stringify({ pid: process.pid, gateway_state: 'running' }),
|
||||
'utf-8',
|
||||
)
|
||||
expect(gatewayStateLooksRunningForProfile(dir)).toBe(true)
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -24,6 +24,7 @@ import * as hermesCli from '../../packages/server/src/services/hermes/hermes-cli
|
||||
|
||||
describe('Profile Routes', () => {
|
||||
const originalHermesHome = process.env.HERMES_HOME
|
||||
const originalWebUiHome = process.env.HERMES_WEB_UI_HOME
|
||||
const tempHomes: string[] = []
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -33,6 +34,8 @@ describe('Profile Routes', () => {
|
||||
afterEach(async () => {
|
||||
if (originalHermesHome === undefined) delete process.env.HERMES_HOME
|
||||
else process.env.HERMES_HOME = originalHermesHome
|
||||
if (originalWebUiHome === undefined) delete process.env.HERMES_WEB_UI_HOME
|
||||
else process.env.HERMES_WEB_UI_HOME = originalWebUiHome
|
||||
await Promise.all(tempHomes.splice(0).map(dir => rm(dir, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
@@ -117,4 +120,69 @@ describe('Profile Routes', () => {
|
||||
expect(existsSync(profileDir)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('profile avatars', () => {
|
||||
it('stores generated avatar metadata under the Web UI home', async () => {
|
||||
const webUiHome = await mkdtemp(join(tmpdir(), 'hermes-web-ui-avatar-'))
|
||||
tempHomes.push(webUiHome)
|
||||
process.env.HERMES_WEB_UI_HOME = webUiHome
|
||||
const { updateAvatar } = await import('../../packages/server/src/controllers/hermes/profiles')
|
||||
const ctx: any = {
|
||||
params: { name: 'work' },
|
||||
request: { body: { type: 'generated', seed: 'custom-seed' } },
|
||||
status: 200,
|
||||
body: undefined,
|
||||
}
|
||||
|
||||
await updateAvatar(ctx)
|
||||
|
||||
const metaPath = join(webUiHome, 'profile-metadata', Buffer.from('work', 'utf-8').toString('base64url'), 'avatar.json')
|
||||
expect(ctx.status).toBe(200)
|
||||
expect(ctx.body.avatar).toMatchObject({ type: 'generated', seed: 'custom-seed' })
|
||||
expect(JSON.parse(readFileSync(metaPath, 'utf-8'))).toMatchObject({
|
||||
type: 'generated',
|
||||
seed: 'custom-seed',
|
||||
})
|
||||
})
|
||||
|
||||
it('stores uploaded image avatars and returns a data URL', async () => {
|
||||
const webUiHome = await mkdtemp(join(tmpdir(), 'hermes-web-ui-avatar-'))
|
||||
tempHomes.push(webUiHome)
|
||||
process.env.HERMES_WEB_UI_HOME = webUiHome
|
||||
const dataUrl = `data:image/png;base64,${Buffer.from('avatar-png').toString('base64')}`
|
||||
const { updateAvatar } = await import('../../packages/server/src/controllers/hermes/profiles')
|
||||
const ctx: any = {
|
||||
params: { name: 'work' },
|
||||
request: { body: { type: 'image', dataUrl } },
|
||||
status: 200,
|
||||
body: undefined,
|
||||
}
|
||||
|
||||
await updateAvatar(ctx)
|
||||
|
||||
const dir = join(webUiHome, 'profile-metadata', Buffer.from('work', 'utf-8').toString('base64url'))
|
||||
const meta = JSON.parse(readFileSync(join(dir, 'avatar.json'), 'utf-8'))
|
||||
expect(ctx.status).toBe(200)
|
||||
expect(ctx.body.avatar).toMatchObject({ type: 'image', dataUrl })
|
||||
expect(meta).toMatchObject({ type: 'image', file: 'avatar.bin', mime: 'image/png' })
|
||||
expect(readFileSync(join(dir, 'avatar.bin')).toString()).toBe('avatar-png')
|
||||
})
|
||||
|
||||
it('deletes profile avatar metadata', async () => {
|
||||
const webUiHome = await mkdtemp(join(tmpdir(), 'hermes-web-ui-avatar-'))
|
||||
tempHomes.push(webUiHome)
|
||||
process.env.HERMES_WEB_UI_HOME = webUiHome
|
||||
const metadataDir = join(webUiHome, 'profile-metadata', Buffer.from('work', 'utf-8').toString('base64url'))
|
||||
await mkdir(metadataDir, { recursive: true })
|
||||
await writeFile(join(metadataDir, 'avatar.json'), '{"type":"generated"}\n', 'utf-8')
|
||||
const { deleteAvatar } = await import('../../packages/server/src/controllers/hermes/profiles')
|
||||
const ctx: any = { params: { name: 'work' }, status: 200, body: undefined }
|
||||
|
||||
await deleteAvatar(ctx)
|
||||
|
||||
expect(ctx.status).toBe(200)
|
||||
expect(ctx.body).toEqual({ success: true })
|
||||
expect(existsSync(metadataDir)).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user