[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
View File
@@ -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
+1
View File
@@ -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>
+14
View File
@@ -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
+14
View File
@@ -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',
+14
View File
@@ -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
+14
View File
@@ -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
+14
View File
@@ -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',
},
},
// ログ
+14
View File
@@ -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',
},
},
// 로그
+14
View File
@@ -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
+14
View File
@@ -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: '恢復預設頭像失敗',
},
},
// 日誌
+14
View File
@@ -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)
+50
View File
@@ -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([])
+32
View File
@@ -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 })
}
})
})
+68
View File
@@ -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)
})
})
})