[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:
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user