Просмотр исходного кода

支持在线同步 Agent 名称与头像

LSamien 2 месяцев назад
Родитель
Сommit
29b70fcba3

+ 28 - 1
hermes-plugins/yaoyao/dashboard/plugin_api.py

@@ -100,6 +100,31 @@ def _hermes_bot_name(profile: Optional[str], profile_home: Optional[Path] = None
     return ""
 
 
+def _hermes_bot_avatar(profile: Optional[str], profile_home: Optional[Path] = None) -> str:
+    """Read the avatar stored with the Hermes-owned Bot metadata."""
+    try:
+        import yaml  # type: ignore
+
+        path = (profile_home or _profile_home(profile)) / "profile.yaml"
+        raw = yaml.safe_load(path.read_text(encoding="utf-8")) if path.is_file() else {}
+        if not isinstance(raw, dict):
+            return ""
+        ui_meta = raw.get("ui_meta")
+        bot_meta = ui_meta.get("hermes-bots") if isinstance(ui_meta, dict) else None
+        avatar = bot_meta.get("avatar") if isinstance(bot_meta, dict) else None
+        if not isinstance(avatar, str):
+            return ""
+        avatar = avatar.strip()
+        if len(avatar) > 512_000 or not avatar.startswith("data:image/"):
+            return ""
+        return avatar
+    except (OSError, TypeError, ValueError):
+        pass
+    except Exception as error:
+        log.warning("Hermes Bot avatar load failed for %s: %s", profile, error)
+    return ""
+
+
 def _load_group_plugin_api():
     module_path = Path(_THIS_DIR) / "group_plugin_api.py"
     module_name = (
@@ -275,7 +300,7 @@ def _to_file_library_item(it: dict) -> dict:
 def list_profiles():
     """Profiles with an existing state.db (i.e. ones the poller watches).
 
-    Returns ``[{name, label, botName, isDefault}]``. ``botName`` comes from
+    Returns ``[{name, label, botName, avatar, isDefault}]``. ``botName`` comes from
     Hermes Bot Mode (then the core profile display name), never YaoYao's
     legacy Agent-name setting. ``default`` is always first when present.
     """
@@ -283,6 +308,7 @@ def list_profiles():
     out = []
     for name, home in pairs:
         bot_name = _hermes_bot_name(name, Path(home))
+        avatar = _hermes_bot_avatar(name, Path(home))
         out.append(
             {
                 "name": name,
@@ -291,6 +317,7 @@ def list_profiles():
                 # Compatibility alias for older YaoYao clients. Its value is
                 # still Hermes-owned Bot metadata, not agent_settings.json.
                 "agentName": bot_name,
+                "avatar": avatar,
                 "isDefault": name == "default",
             }
         )

+ 65 - 1
src/client/api/profiles.ts

@@ -1,6 +1,70 @@
-import type { ModelOption, Profile } from '@shared/types'
+import type { JsonValue, ModelOption, Profile } from '@shared/types'
 import { apiRequest, unwrapData } from './client'
 import { normalizeModel, normalizeProfile, record, values } from '@/utils/normalize'
+import { ChatRpcSocket } from './realtime'
+
+const MAX_AGENT_NAME_LENGTH = 100
+const MAX_AGENT_AVATAR_LENGTH = 512_000
+const avatarDataURLPattern = /^data:image\/(png|jpeg|webp);base64,[A-Za-z0-9+/]+={0,2}$/i
+
+export type ProfileIdentityInput = {
+  title: string
+  avatarDataURL: string | null
+}
+
+function jsonObject(value: unknown): Record<string, JsonValue> {
+  return record(value) as Record<string, JsonValue>
+}
+
+function readProfileEntries(value: JsonValue): Record<string, JsonValue>[] {
+  const source = jsonObject(value)
+  return values(source.profiles).flatMap(item => {
+    const outer = jsonObject(item)
+    const profile = jsonObject(outer.profile)
+    return Object.keys(profile).length ? [profile] : [outer]
+  })
+}
+
+function validateIdentity(input: ProfileIdentityInput): ProfileIdentityInput {
+  const title = input.title.trim().replace(/\s+/g, ' ')
+  if (!title || title.length > MAX_AGENT_NAME_LENGTH || /[\u0000-\u001f\u007f]/.test(title)) {
+    throw new Error(`Agent 名称应为 1 至 ${MAX_AGENT_NAME_LENGTH} 个字符`)
+  }
+  const avatarDataURL = input.avatarDataURL?.trim() || null
+  if (avatarDataURL && (avatarDataURL.length > MAX_AGENT_AVATAR_LENGTH || !avatarDataURLPattern.test(avatarDataURL))) {
+    throw new Error('头像必须是小于 375 KB 的 PNG、JPEG 或 WebP 图片')
+  }
+  return { title, avatarDataURL }
+}
+
+/**
+ * Hermes owns this metadata in profile.yaml. Reading it immediately before the
+ * write preserves Bot chat bindings and lets the upstream revision guard
+ * reject a concurrent edit instead of silently overwriting it.
+ */
+export async function updateProfileIdentity(profile: Profile, input: ProfileIdentityInput): Promise<void> {
+  const identity = validateIdentity(input)
+  const control = new ChatRpcSocket()
+  await control.connect()
+  try {
+    const listed = await control.request('profiles.list', { include_sessions: false })
+    const current = readProfileEntries(listed).find(item => String(item.name ?? '') === profile.name)
+    if (!current) throw new Error('该 Agent 已不存在,请刷新后重试')
+    const uiMeta = jsonObject(current.ui_meta)
+    const botMeta: Record<string, JsonValue> = { ...jsonObject(uiMeta['hermes-bots']), title: identity.title }
+    if (identity.avatarDataURL) botMeta.avatar = identity.avatarDataURL
+    else delete botMeta.avatar
+    const revisions = jsonObject(current.ui_meta_revisions)
+    const revision = revisions['hermes-bots']
+    await control.request('profiles.configure', {
+      name: profile.name,
+      ui_meta: { 'hermes-bots': botMeta },
+      ...(typeof revision === 'number' ? { ui_meta_expected_revisions: { 'hermes-bots': revision } } : {}),
+    })
+  } finally {
+    control.close()
+  }
+}
 
 export async function getProfiles(): Promise<Profile[]> {
   const payload = unwrapData(await apiRequest<unknown>('/api/app/profiles'))

Разница между файлами не показана из-за своего большого размера
+ 94 - 0
src/client/components/app/AgentIdentityDialog.vue


+ 34 - 24
src/client/components/app/WorkspaceShell.vue

@@ -1,6 +1,8 @@
 <script setup lang="ts">
 import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
 import { useRoute, useRouter } from 'vue-router'
+import type { Profile } from '@shared/types'
+import AgentAvatar from '@/components/common/AgentAvatar.vue'
 import AppIcon from '@/components/common/AppIcon.vue'
 import BrandMark from '@/components/common/BrandMark.vue'
 import NodePairingDialog from '@/components/app/NodePairingDialog.vue'
@@ -20,8 +22,8 @@ const SIDEBAR_SEARCH_CLOSE_EVENT = 'hermes-yaoyao:sidebar-search-close'
 const props = withDefaults(defineProps<{
   userName?: string
   pairingUserName?: string
-  profileName?: string
-  profiles?: string[]
+  activeProfile?: Profile
+  profiles?: Profile[]
   theme?: 'light' | 'dark'
   insecureTransport?: boolean
   sidebarTitle?: string
@@ -32,7 +34,7 @@ const props = withDefaults(defineProps<{
 }>(), {
   userName: '',
   pairingUserName: '',
-  profileName: '',
+  activeProfile: undefined,
   profiles: () => [],
   theme: 'light',
   insecureTransport: false,
@@ -47,6 +49,7 @@ const emit = defineEmits<{
   logout: []
   toggleTheme: []
   selectProfile: [profile: string]
+  editProfile: []
   closeInspector: []
 }>()
 
@@ -60,6 +63,10 @@ const nodePairingOpen = ref(false)
 const desktopSidebarContext = ref<HTMLElement | null>(null)
 const mobileSidebarContext = ref<HTMLElement | null>(null)
 
+function profileTitle(profile?: Profile): string {
+  return profile?.agentName || profile?.displayName || profile?.name || '未选择 Agent'
+}
+
 const navItems: NavItem[] = [
   { key: 'chat', label: '对话', path: '/chat', icon: 'chat' },
   { key: 'groups', label: '群聊', path: '/groups', icon: 'groups' },
@@ -251,11 +258,11 @@ onBeforeUnmount(() => {
 
       <div class="sidebar-footer">
         <div class="sidebar-account-switcher">
-          <button class="sidebar-account-switcher__main" type="button" :title="`切换 Agent:${profileName || profiles[0] || '未选择'}`" @click="profileMenuOpen = !profileMenuOpen">
-            <span class="sidebar-account__avatar">{{ (userName || '夭').slice(0, 1).toUpperCase() }}</span>
+          <button class="sidebar-account-switcher__main" type="button" :title="`切换 Agent:${profileTitle(activeProfile)}`" @click="profileMenuOpen = !profileMenuOpen">
+            <AgentAvatar :name="profileTitle(activeProfile)" :avatar="activeProfile?.agentAvatar || ''" :size="30" />
             <span class="account-copy">
-              <strong>{{ userName || 'Hermes 用户' }}</strong>
-              <span>{{ profileName || profiles[0] || '未选择 Agent' }}</span>
+              <strong>{{ profileTitle(activeProfile) }}</strong>
+              <span>{{ activeProfile?.name || userName || '未选择 Agent' }}</span>
             </span>
             <AppIcon class="sidebar-account-switcher__chevron" name="chevron-down" :size="14" />
           </button>
@@ -269,15 +276,16 @@ onBeforeUnmount(() => {
             <div v-if="profileMenuOpen" class="profile-menu">
               <button
                 v-for="profile in profiles"
-                :key="profile"
+                :key="profile.name"
                 type="button"
-                :class="{ active: profile === profileName }"
-                @click="emit('selectProfile', profile); profileMenuOpen = false"
+                :class="{ active: profile.name === activeProfile?.name }"
+                @click="emit('selectProfile', profile.name); profileMenuOpen = false"
               >
-                <span>{{ profile.slice(0, 1).toUpperCase() }}</span>
-                <strong>{{ profile }}</strong>
-                <AppIcon v-if="profile === profileName" name="check" :size="15" />
+                <AgentAvatar :name="profileTitle(profile)" :avatar="profile.agentAvatar || ''" :size="24" />
+                <strong>{{ profileTitle(profile) }}</strong>
+                <AppIcon v-if="profile.name === activeProfile?.name" name="check" :size="15" />
               </button>
+              <button class="profile-menu__edit" type="button" :disabled="!activeProfile" @click="profileMenuOpen = false; emit('editProfile')"><AppIcon name="settings" :size="15" /><strong>编辑当前 Agent</strong></button>
               <button class="profile-menu__logout" type="button" @click="profileMenuOpen = false; emit('logout')">
                 <AppIcon name="logout" :size="15" /><strong>退出登录</strong>
               </button>
@@ -360,11 +368,11 @@ onBeforeUnmount(() => {
 
       <div class="sidebar-footer mobile-drawer__footer">
         <div class="sidebar-account-switcher">
-          <button class="sidebar-account-switcher__main" type="button" :title="`切换 Agent:${profileName || profiles[0] || '未选择'}`" @click="profileMenuOpen = !profileMenuOpen">
-            <span class="sidebar-account__avatar">{{ (userName || '夭').slice(0, 1).toUpperCase() }}</span>
+          <button class="sidebar-account-switcher__main" type="button" :title="`切换 Agent:${profileTitle(activeProfile)}`" @click="profileMenuOpen = !profileMenuOpen">
+            <AgentAvatar :name="profileTitle(activeProfile)" :avatar="activeProfile?.agentAvatar || ''" :size="30" />
             <span class="account-copy">
-              <strong>{{ userName || 'Hermes 用户' }}</strong>
-              <span>{{ profileName || profiles[0] || '未选择 Agent' }}</span>
+              <strong>{{ profileTitle(activeProfile) }}</strong>
+              <span>{{ activeProfile?.name || userName || '未选择 Agent' }}</span>
             </span>
             <AppIcon class="sidebar-account-switcher__chevron" name="chevron-down" :size="14" />
           </button>
@@ -378,15 +386,16 @@ onBeforeUnmount(() => {
             <div v-if="profileMenuOpen" class="profile-menu">
               <button
                 v-for="profile in profiles"
-                :key="profile"
+                :key="profile.name"
                 type="button"
-                :class="{ active: profile === profileName }"
-                @click="emit('selectProfile', profile); profileMenuOpen = false"
+                :class="{ active: profile.name === activeProfile?.name }"
+                @click="emit('selectProfile', profile.name); profileMenuOpen = false"
               >
-                <span>{{ profile.slice(0, 1).toUpperCase() }}</span>
-                <strong>{{ profile }}</strong>
-                <AppIcon v-if="profile === profileName" name="check" :size="15" />
+                <AgentAvatar :name="profileTitle(profile)" :avatar="profile.agentAvatar || ''" :size="24" />
+                <strong>{{ profileTitle(profile) }}</strong>
+                <AppIcon v-if="profile.name === activeProfile?.name" name="check" :size="15" />
               </button>
+              <button class="profile-menu__edit" type="button" :disabled="!activeProfile" @click="profileMenuOpen = false; emit('editProfile')"><AppIcon name="settings" :size="15" /><strong>编辑当前 Agent</strong></button>
               <button class="profile-menu__logout" type="button" @click="profileMenuOpen = false; emit('logout')">
                 <AppIcon name="logout" :size="15" /><strong>退出登录</strong>
               </button>
@@ -560,8 +569,9 @@ onBeforeUnmount(() => {
 .profile-menu { position: absolute; right: 0; bottom: calc(100% + 6px); left: 0; padding: 5px; border: 1px solid var(--line); border-radius: 12px; background: var(--surface-raised); box-shadow: var(--shadow-float); }
 .profile-menu button { display: flex; width: 100%; min-height: 38px; align-items: center; gap: 9px; padding: 5px 8px; border: 0; border-radius: 8px; background: transparent; cursor: pointer; text-align: left; }
 .profile-menu button:hover, .profile-menu button.active { background: var(--surface-hover); }
-.profile-menu button > span { display: grid; width: 25px; height: 25px; place-items: center; border-radius: 8px; background: var(--surface-soft); color: var(--text-secondary); font-size: 10px; }
+.profile-menu button > span:not(.agent-avatar) { display: grid; width: 25px; height: 25px; place-items: center; border-radius: 8px; background: var(--surface-soft); color: var(--text-secondary); font-size: 10px; }
 .profile-menu button strong { flex: 1; overflow: hidden; font-size: 12px; text-overflow: ellipsis; }
+.profile-menu__edit { margin-top: 4px; border-top: 1px solid var(--line) !important; color: var(--text-secondary); }
 .profile-menu__logout { margin-top: 4px; border-top: 1px solid var(--line) !important; color: var(--danger); }
 .account-copy { display: flex; min-width: 0; flex: 1; flex-direction: column; }
 .account-copy strong, .account-copy span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

+ 24 - 0
src/client/components/common/AgentAvatar.vue

@@ -0,0 +1,24 @@
+<script setup lang="ts">
+import { computed } from 'vue'
+
+const props = withDefaults(defineProps<{
+  name: string
+  avatar?: string
+  size?: number
+}>(), { avatar: '', size: 32 })
+
+const initials = computed(() => (props.name.trim() || 'A').slice(0, 1).toLocaleUpperCase())
+const hasAvatar = computed(() => /^data:image\/(png|jpeg|webp);base64,/i.test(props.avatar))
+</script>
+
+<template>
+  <span class="agent-avatar" :style="{ width: `${size}px`, height: `${size}px` }" :aria-label="`${name} 的头像`">
+    <img v-if="hasAvatar" :src="avatar" alt="" />
+    <span v-else>{{ initials }}</span>
+  </span>
+</template>
+
+<style scoped>
+.agent-avatar { display: grid; flex: 0 0 auto; place-items: center; overflow: hidden; border-radius: 50%; background: var(--accent); color: var(--text-on-solid); font-size: calc(v-bind(size) * .36); font-weight: 700; line-height: 1; }
+.agent-avatar img { width: 100%; height: 100%; object-fit: cover; }
+</style>

+ 29 - 7
src/client/components/workspace/WorkspaceView.vue

@@ -1,6 +1,8 @@
 <script setup lang="ts">
-import { computed } from 'vue'
+import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
 import WorkspaceShell from '@/components/app/WorkspaceShell.vue'
+import AgentIdentityDialog from '@/components/app/AgentIdentityDialog.vue'
+import { updateProfileIdentity } from '@/api/profiles'
 import { useAuthStore } from '@/stores/auth'
 import { useThemeStore } from '@/stores/theme'
 
@@ -17,20 +19,38 @@ const auth = useAuthStore()
 const theme = useThemeStore()
 
 const userName = computed(() => auth.user?.displayName || auth.user?.username || '')
-const profileName = computed(() => auth.activeProfileName)
-const profileNames = computed(() => auth.profiles.map(profile => profile.agentName || profile.displayName || profile.name))
-const profileNameMap = computed(() => new Map(auth.profiles.map(profile => [profile.agentName || profile.displayName || profile.name, profile.name])))
+const identityOpen = ref(false)
+const identityBusy = ref(false)
+const identityError = ref('')
 
 async function logout() { await auth.logout() }
-function selectProfile(label: string) { auth.selectProfile(profileNameMap.value.get(label) || label) }
+function selectProfile(name: string) { auth.selectProfile(name) }
+async function saveIdentity(input: { title: string; avatarDataURL: string | null }) {
+  const profile = auth.activeProfile
+  if (!profile) return
+  identityBusy.value = true
+  identityError.value = ''
+  try {
+    await updateProfileIdentity(profile, input)
+    await auth.refreshProfiles()
+    identityOpen.value = false
+  } catch (cause) {
+    identityError.value = cause instanceof Error ? cause.message : '保存 Agent 身份失败'
+  } finally {
+    identityBusy.value = false
+  }
+}
+function refreshOnFocus() { void auth.refreshProfiles().catch(() => undefined) }
+onMounted(() => window.addEventListener('focus', refreshOnFocus))
+onBeforeUnmount(() => window.removeEventListener('focus', refreshOnFocus))
 </script>
 
 <template>
   <WorkspaceShell
     :user-name="userName"
     :pairing-user-name="auth.user?.username || ''"
-    :profile-name="auth.activeProfile?.agentName || auth.activeProfile?.displayName || profileName"
-    :profiles="profileNames"
+    :active-profile="auth.activeProfile"
+    :profiles="auth.profiles"
     :theme="theme.resolvedTheme"
     :insecure-transport="auth.insecureLan"
     :sidebar-title="sidebarTitle"
@@ -41,6 +61,7 @@ function selectProfile(label: string) { auth.selectProfile(profileNameMap.value.
     @logout="logout"
     @toggle-theme="theme.toggle"
     @select-profile="selectProfile"
+    @edit-profile="identityOpen = true"
     @close-inspector="emit('closeInspector')"
   >
     <template #sidebar-action><slot name="sidebar-action" /></template>
@@ -50,4 +71,5 @@ function selectProfile(label: string) { auth.selectProfile(profileNameMap.value.
     <template #default><slot /></template>
     <template #inspector><slot name="inspector" /></template>
   </WorkspaceShell>
+  <AgentIdentityDialog :open="identityOpen" :profile="auth.activeProfile" :busy="identityBusy" :error="identityError" @close="identityOpen = false" @save="saveIdentity" />
 </template>

+ 9 - 1
src/client/stores/auth.ts

@@ -92,8 +92,16 @@ export const useAuthStore = defineStore('auth', () => {
     activeProfileName.value = name
   }
 
+  async function refreshProfiles(): Promise<void> {
+    const nextProfiles = await authApi.fetchProfiles()
+    profiles.value = nextProfiles
+    if (!nextProfiles.some(profile => profile.name === activeProfileName.value)) {
+      activeProfileName.value = nextProfiles.find(profile => profile.isDefault)?.name ?? nextProfiles[0]?.name ?? ''
+    }
+  }
+
   return {
     status, user, profiles, activeProfileName, activeProfile, csrfToken, error, authRequired, insecureLan, groupUploadsEnabled,
-    isAuthenticated, bootstrap, login, logout, selectProfile, expire,
+    isAuthenticated, bootstrap, login, logout, selectProfile, refreshProfiles, expire,
   }
 })

+ 1 - 0
src/client/utils/normalize.ts

@@ -81,6 +81,7 @@ export function normalizeProfile(value: unknown): Profile {
       ? undefined
       : bool(pick(source, 'gateway_running', 'gatewayRunning')),
     avatar: string(source.avatar) || undefined,
+    agentAvatar: string(pick(source, 'agentAvatar', 'agent_avatar')) || undefined,
   }
 }
 

+ 14 - 4
src/server/routes.ts

@@ -125,20 +125,30 @@ function normalizedProfiles(value: JsonObject): unknown[] {
 
 function profilesWithHermesBotNames(profiles: unknown[], pluginProfiles: JsonObject | undefined): unknown[] {
   const items = Array.isArray(pluginProfiles?.profiles) ? pluginProfiles.profiles : []
-  const names = new Map<string, string>()
+  const identities = new Map<string, { name?: string; avatar?: string }>()
   for (const entry of items) {
     if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue
     const profile = entry as JsonObject
     const name = typeof profile.name === 'string' ? profile.name : ''
     const botName = typeof profile.botName === 'string' ? profile.botName.trim() : ''
-    if (name && botName) names.set(name, botName)
+    const avatar = typeof profile.avatar === 'string' && profile.avatar.startsWith('data:image/')
+      ? profile.avatar
+      : ''
+    if (name && (botName || avatar)) identities.set(name, {
+      ...(botName ? { name: botName } : {}),
+      ...(avatar ? { avatar } : {}),
+    })
   }
   return profiles.map(entry => {
     if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return entry
     const profile = entry as JsonObject
     const name = typeof profile.name === 'string' ? profile.name : typeof profile.profile === 'string' ? profile.profile : ''
-    const agentName = names.get(name)
-    return agentName ? { ...profile, agentName } : profile
+    const identity = identities.get(name)
+    return identity ? {
+      ...profile,
+      ...(identity.name ? { agentName: identity.name } : {}),
+      ...(identity.avatar ? { agentAvatar: identity.avatar } : {}),
+    } : profile
   })
 }
 

+ 1 - 0
src/shared/types.ts

@@ -28,6 +28,7 @@ export interface Profile {
   provider?: string
   gatewayRunning?: boolean
   avatar?: string
+  agentAvatar?: string
 }
 
 export interface ModelOption {

+ 3 - 3
tests/server/app.test.ts

@@ -79,7 +79,7 @@ function fakeGateway(records: RecordedRequest[]): typeof fetch {
       case '/api/profiles':
         return jsonResponse({ profiles: [{ name: 'default', is_default: true }] })
       case '/api/plugins/yaoyao/profiles':
-        return jsonResponse({ profiles: [{ name: 'default', botName: '竹儿', agentName: '旧插件名称', isDefault: true }] })
+        return jsonResponse({ profiles: [{ name: 'default', botName: '竹儿', avatar: 'data:image/png;base64,AA==', agentName: '旧插件名称', isDefault: true }] })
       case '/api/auth/providers':
         return jsonResponse({ providers: [{ name: 'basic', supports_password: true }] })
       case '/auth/password-login':
@@ -180,7 +180,7 @@ describe('8800 BFF', () => {
       authRequired: true,
       authenticated: true,
       user: { user_id: 'user-1' },
-      profiles: [{ name: 'default', agentName: '竹儿' }],
+      profiles: [{ name: 'default', agentName: '竹儿', agentAvatar: 'data:image/png;base64,AA==' }],
       groupUploadsEnabled: true,
     })
     expect(response.body.csrfToken).toEqual(expect.any(String))
@@ -204,7 +204,7 @@ describe('8800 BFF', () => {
       '/api/profiles', '/api/plugins/yaoyao/profiles',
     ])
     expect(response.body.profiles).toEqual([
-      { name: 'default', is_default: true, agentName: '竹儿' },
+      { name: 'default', is_default: true, agentName: '竹儿', agentAvatar: 'data:image/png;base64,AA==' },
     ])
     expect(JSON.stringify(response.body)).not.toContain('旧插件名称')
   })

Некоторые файлы не были показаны из-за большого количества измененных файлов