Răsfoiți Sursa

接入 Desktop Bots 原生身份设置

LSamien 2 luni în urmă
părinte
comite
e27ba11dea

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

@@ -100,31 +100,6 @@ 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 = (
@@ -300,7 +275,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, avatar, isDefault}]``. ``botName`` comes from
+    Returns ``[{name, label, botName, 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.
     """
@@ -308,7 +283,6 @@ 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,
@@ -317,7 +291,6 @@ 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",
             }
         )

+ 5 - 0
src/client/api/auth.ts

@@ -1,6 +1,7 @@
 import type { BootstrapResponse, CurrentUser, JsonValue, Profile } from '@shared/types'
 import { apiRequest, setApiCsrfToken, unwrapData } from './client'
 import { bool, normalizeProfile, normalizeUser, record, string, values } from '@/utils/normalize'
+import { getProfileAvatarDataURLs } from './profiles'
 
 function normalizeBootstrap(payload: unknown): BootstrapResponse {
   const root = record(unwrapData(payload as never))
@@ -53,6 +54,10 @@ export async function fetchProfiles(): Promise<Profile[]> {
   return values(payload.profiles ?? payload.items ?? payload).map(normalizeProfile)
 }
 
+export async function fetchProfileAvatars(profiles: Profile[]): Promise<Record<string, string>> {
+  return getProfileAvatarDataURLs(profiles)
+}
+
 export async function fetchCurrentUser(): Promise<CurrentUser | undefined> {
   const response = await bootstrap()
   return response.user

+ 39 - 9
src/client/api/profiles.ts

@@ -4,12 +4,13 @@ import { normalizeModel, normalizeProfile, record, values } from '@/utils/normal
 import { ChatRpcSocket } from './realtime'
 
 const MAX_AGENT_NAME_LENGTH = 100
-const MAX_AGENT_AVATAR_LENGTH = 512_000
+const MAX_AGENT_AVATAR_LENGTH = 2_800_000
 const avatarDataURLPattern = /^data:image\/(png|jpeg|webp);base64,[A-Za-z0-9+/]+={0,2}$/i
 
 export type ProfileIdentityInput = {
   title: string
-  avatarDataURL: string | null
+  /** Undefined means the user did not change the native Desktop avatar. */
+  avatarDataURL?: string | null
 }
 
 function jsonObject(value: unknown): Record<string, JsonValue> {
@@ -30,17 +31,17 @@ function validateIdentity(input: ProfileIdentityInput): ProfileIdentityInput {
   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
+  const avatarDataURL = input.avatarDataURL === undefined ? undefined : input.avatarDataURL?.trim() || null
   if (avatarDataURL && (avatarDataURL.length > MAX_AGENT_AVATAR_LENGTH || !avatarDataURLPattern.test(avatarDataURL))) {
-    throw new Error('头像必须是小于 375 KB 的 PNG、JPEG 或 WebP 图片')
+    throw new Error('头像必须是小于 2 MB 的 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.
+ * Desktop Bots owns both values: its title lives in the Hermes metadata
+ * namespace and its avatar is the native profile asset. Reading metadata
+ * before writing preserves Bot chat bindings.
  */
 export async function updateProfileIdentity(profile: Profile, input: ProfileIdentityInput): Promise<void> {
   const identity = validateIdentity(input)
@@ -52,8 +53,6 @@ export async function updateProfileIdentity(profile: Profile, input: ProfileIden
     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', {
@@ -61,6 +60,37 @@ export async function updateProfileIdentity(profile: Profile, input: ProfileIden
       ui_meta: { 'hermes-bots': botMeta },
       ...(typeof revision === 'number' ? { ui_meta_expected_revisions: { 'hermes-bots': revision } } : {}),
     })
+    if (identity.avatarDataURL !== undefined) {
+      await control.request('profiles.set_asset', {
+        name: profile.name,
+        asset: 'avatar',
+        ...(identity.avatarDataURL ? { data: identity.avatarDataURL } : { clear: true }),
+      })
+    }
+  } finally {
+    control.close()
+  }
+}
+
+/** Fetch the native Desktop Bots avatar asset for every profile that has one. */
+export async function getProfileAvatarDataURLs(profiles: Profile[]): Promise<Record<string, string>> {
+  if (!profiles.length) return {}
+  const allowed = new Set(profiles.map(profile => profile.name))
+  const control = new ChatRpcSocket()
+  await control.connect()
+  try {
+    const listed = await control.request('profiles.list', { include_sessions: false })
+    const names = readProfileEntries(listed)
+      .filter(item => allowed.has(String(item.name ?? '')) && item.has_avatar === true)
+      .map(item => String(item.name))
+    const entries = await Promise.all(names.map(async name => {
+      const asset = jsonObject(await control.request('profiles.get_asset', { name, asset: 'avatar' }))
+      const data = typeof asset.data === 'string' ? asset.data : ''
+      return data && data.length <= MAX_AGENT_AVATAR_LENGTH && avatarDataURLPattern.test(data)
+        ? [name, data] as const
+        : undefined
+    }))
+    return Object.fromEntries(entries.filter((entry): entry is readonly [string, string] => entry !== undefined))
   } finally {
     control.close()
   }

+ 6 - 3
src/client/components/app/AgentIdentityDialog.vue

@@ -7,11 +7,12 @@ import AppIcon from '@/components/common/AppIcon.vue'
 const props = defineProps<{ open: boolean; profile?: Profile; busy?: boolean; error?: string }>()
 const emit = defineEmits<{
   close: []
-  save: [input: { title: string; avatarDataURL: string | null }]
+  save: [input: { title: string; avatarDataURL?: string | null }]
 }>()
 
 const title = ref('')
 const avatarDataURL = ref<string | null>(null)
+const avatarTouched = ref(false)
 const localError = ref('')
 const fileInput = ref<HTMLInputElement>()
 
@@ -19,6 +20,7 @@ watch(() => [props.open, props.profile] as const, () => {
   if (!props.open) return
   title.value = props.profile?.agentName || props.profile?.displayName || props.profile?.name || ''
   avatarDataURL.value = props.profile?.agentAvatar || null
+  avatarTouched.value = false
   localError.value = ''
 }, { immediate: true })
 
@@ -57,6 +59,7 @@ async function chooseAvatar(event: Event) {
     if (!['image/png', 'image/jpeg', 'image/webp'].includes(file.type)) throw new Error('请选择 PNG、JPEG 或 WebP 图片')
     if (file.size > 10 * 1024 * 1024) throw new Error('图片不能超过 10 MB')
     avatarDataURL.value = await resizeImage(await readImage(file))
+    avatarTouched.value = true
     localError.value = ''
   } catch (cause) {
     localError.value = cause instanceof Error ? cause.message : '处理头像失败'
@@ -71,7 +74,7 @@ function submit() {
     localError.value = '请输入 Agent 名称'
     return
   }
-  emit('save', { title: normalized, avatarDataURL: avatarDataURL.value })
+  emit('save', { title: normalized, ...(avatarTouched.value ? { avatarDataURL: avatarDataURL.value } : {}) })
 }
 </script>
 
@@ -81,7 +84,7 @@ function submit() {
       <div v-if="open && profile" class="identity-layer" @mousedown.self="emit('close')">
         <form class="identity-dialog" @submit.prevent="submit">
           <header><div><small>AGENT 身份</small><h2>编辑 {{ profile.name }}</h2></div><button class="icon-button" type="button" aria-label="关闭" :disabled="busy" @click="emit('close')"><AppIcon name="close" /></button></header>
-          <div class="identity-avatar"><AgentAvatar :name="title || profile.name" :avatar="avatarDataURL || ''" :size="76" /><div><strong>头像</strong><small>会压缩为 256 px,并同步到所有设备。</small><p><button class="quiet-button" type="button" :disabled="busy" @click="fileInput?.click()"><AppIcon name="image" :size="14" />选择图片</button><button v-if="avatarDataURL" class="quiet-button" type="button" :disabled="busy" @click="avatarDataURL = null">移除</button></p></div><input ref="fileInput" class="sr-only" type="file" accept="image/png,image/jpeg,image/webp" @change="chooseAvatar" /></div>
+          <div class="identity-avatar"><AgentAvatar :name="title || profile.name" :avatar="avatarDataURL || ''" :size="76" /><div><strong>Desktop Bots 头像</strong><small>会压缩为 256 px,并写入 Desktop 原生 Agent 头像;所有设备同步。</small><p><button class="quiet-button" type="button" :disabled="busy" @click="fileInput?.click()"><AppIcon name="image" :size="14" />选择图片</button><button v-if="avatarDataURL" class="quiet-button" type="button" :disabled="busy" @click="avatarDataURL = null; avatarTouched = true">移除</button></p></div><input ref="fileInput" class="sr-only" type="file" accept="image/png,image/jpeg,image/webp" @change="chooseAvatar" /></div>
           <label>名称<input v-model="title" :disabled="busy" maxlength="100" autocomplete="off" /></label>
           <p v-if="localError || error" class="identity-error" role="alert">{{ localError || error }}</p>
           <footer><button class="quiet-button" type="button" :disabled="busy" @click="emit('close')">取消</button><button class="primary-button" type="submit" :disabled="busy">{{ busy ? '正在同步…' : '保存并同步' }}</button></footer>

+ 4 - 1
src/client/components/messages/MessageTimeline.vue

@@ -1,6 +1,7 @@
 <script setup lang="ts">
 import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
 import AppIcon from '@/components/common/AppIcon.vue'
+import AgentAvatar from '@/components/common/AgentAvatar.vue'
 import EmptyState from '@/components/common/EmptyState.vue'
 import InteractionCard from './InteractionCard.vue'
 import MarkdownContent from './MarkdownContent.vue'
@@ -29,6 +30,7 @@ const props = withDefaults(defineProps<{
   showAssistantIdentity?: boolean
   interaction?: UiInteraction | null
   mentionNames?: string[]
+  agentAvatars?: Record<string, string>
   thinking?: boolean
 }>(), {
   title: '',
@@ -47,6 +49,7 @@ const props = withDefaults(defineProps<{
   showAssistantIdentity: true,
   interaction: null,
   mentionNames: () => [],
+  agentAvatars: () => ({}),
   thinking: false,
 })
 
@@ -256,7 +259,7 @@ defineExpose({ scrollToMessage, scrollToAnchor, scrollToBottom })
             'message--assistant-anonymous': message.role === 'assistant' && !showAssistantIdentity,
           }]"
         >
-          <div v-if="message.role !== 'user' && (message.role !== 'assistant' || showAssistantIdentity)" class="message__avatar">{{ (message.author || message.profile || (message.role === 'assistant' ? '夭' : '系')).slice(0, 1).toUpperCase() }}</div>
+          <AgentAvatar v-if="message.role !== 'user' && (message.role !== 'assistant' || showAssistantIdentity)" class="message__avatar" :name="message.author || message.profile || (message.role === 'assistant' ? '夭' : '系')" :avatar="message.profile ? agentAvatars[message.profile] || '' : ''" :size="27" />
           <div class="message__body">
             <template v-if="message.timelineKind === 'delegation-complete'">
               <details class="delegation-event">

+ 16 - 5
src/client/components/workspace/WorkspaceView.vue

@@ -25,7 +25,7 @@ const identityError = ref('')
 
 async function logout() { await auth.logout() }
 function selectProfile(name: string) { auth.selectProfile(name) }
-async function saveIdentity(input: { title: string; avatarDataURL: string | null }) {
+async function saveIdentity(input: { title: string; avatarDataURL?: string | null }) {
   const profile = auth.activeProfile
   if (!profile) return
   identityBusy.value = true
@@ -33,6 +33,7 @@ async function saveIdentity(input: { title: string; avatarDataURL: string | null
   try {
     await updateProfileIdentity(profile, input)
     await auth.refreshProfiles()
+    await auth.refreshProfileAvatars()
     identityOpen.value = false
   } catch (cause) {
     identityError.value = cause instanceof Error ? cause.message : '保存 Agent 身份失败'
@@ -40,9 +41,19 @@ async function saveIdentity(input: { title: string; avatarDataURL: string | null
     identityBusy.value = false
   }
 }
-function refreshOnFocus() { void auth.refreshProfiles().catch(() => undefined) }
-onMounted(() => window.addEventListener('focus', refreshOnFocus))
-onBeforeUnmount(() => window.removeEventListener('focus', refreshOnFocus))
+async function refreshOnFocus() {
+  await auth.refreshProfiles()
+  await auth.refreshProfileAvatars()
+}
+function refreshOnWindowFocus() { void refreshOnFocus().catch(() => undefined) }
+function openIdentity() {
+  void auth.refreshProfileAvatars().finally(() => { identityOpen.value = true })
+}
+onMounted(() => {
+  window.addEventListener('focus', refreshOnWindowFocus)
+  void auth.refreshProfileAvatars().catch(() => undefined)
+})
+onBeforeUnmount(() => window.removeEventListener('focus', refreshOnWindowFocus))
 </script>
 
 <template>
@@ -61,7 +72,7 @@ onBeforeUnmount(() => window.removeEventListener('focus', refreshOnFocus))
     @logout="logout"
     @toggle-theme="theme.toggle"
     @select-profile="selectProfile"
-    @edit-profile="identityOpen = true"
+    @edit-profile="openIdentity"
     @close-inspector="emit('closeInspector')"
   >
     <template #sidebar-action><slot name="sidebar-action" /></template>

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

@@ -100,8 +100,16 @@ export const useAuthStore = defineStore('auth', () => {
     }
   }
 
+  async function refreshProfileAvatars(): Promise<void> {
+    const avatars = await authApi.fetchProfileAvatars(profiles.value)
+    profiles.value = profiles.value.map(profile => ({
+      ...profile,
+      ...(avatars[profile.name] ? { agentAvatar: avatars[profile.name] } : {}),
+    }))
+  }
+
   return {
     status, user, profiles, activeProfileName, activeProfile, csrfToken, error, authRequired, insecureLan, groupUploadsEnabled,
-    isAuthenticated, bootstrap, login, logout, selectProfile, refreshProfiles, expire,
+    isAuthenticated, bootstrap, login, logout, selectProfile, refreshProfiles, refreshProfileAvatars, expire,
   }
 })

+ 4 - 0
src/client/views/ChatView.vue

@@ -55,6 +55,9 @@ const agentNames = computed(() => new Map(auth.profiles.map(profile => [
   profile.name,
   profile.agentName || profile.displayName || profile.name,
 ])))
+const agentAvatars = computed(() => Object.fromEntries(auth.profiles.flatMap(profile =>
+  profile.agentAvatar ? [[profile.name, profile.agentAvatar] as const] : []
+)))
 const sidebarItems = computed(() => sessions.value.map(session => sessionSidebarItem(
   session,
   chat.unreadCounts[session.id] ?? 0,
@@ -409,6 +412,7 @@ watch(() => chat.activeSessionId, async id => {
         :show-tools="showThinking"
         :thinking="chat.isSending || chat.isStreaming"
         :show-assistant-identity="false"
+        :agent-avatars="agentAvatars"
         :interaction="interaction"
         empty-logo
         transparent-header

+ 6 - 1
src/client/views/GroupsView.vue

@@ -4,6 +4,7 @@ import { useRoute, useRouter } from 'vue-router'
 import { SUPPORTED_GROUP_PROTOCOL_VERSION_LABEL } from '@shared/types'
 import type { GroupAgent, GroupRoomSummary, GroupTopicSummary, ModelOption } from '@shared/types'
 import AppIcon from '@/components/common/AppIcon.vue'
+import AgentAvatar from '@/components/common/AgentAvatar.vue'
 import YaoYaoSidebarIcon from '@/components/common/YaoYaoSidebarIcon.vue'
 import EmptyState from '@/components/common/EmptyState.vue'
 import ComposerShell from '@/components/composer/ComposerShell.vue'
@@ -137,6 +138,9 @@ const messages = computed(() => groups.messages.map(message => groupMessageToUi(
 const conversationMediaItems = computed(() => mediaItemsFromMessages(messages.value))
 const lightboxMedia = computed(() => conversationMediaItems.value.map(item => ({ url: item.previewUrl || item.downloadUrl || '', name: item.name, type: item.kind as 'image' | 'video' })).filter(item => item.url))
 const agents = computed(() => groups.agents.map(agentToUi))
+const agentAvatars = computed(() => Object.fromEntries(auth.profiles.flatMap(profile =>
+  profile.agentAvatar ? [[profile.name, profile.agentAvatar] as const] : []
+)))
 function serverAddress(value: string): string {
   try { return new URL(value).host }
   catch { return value.replace(/^https?:\/\//i, '').replace(/\/.*$/, '') }
@@ -651,6 +655,7 @@ watch(() => auth.activeProfile?.name, profile => { if (profile) restoreShowThink
         :show-tools="showThinking"
         :interaction="activeInteraction"
         :mention-names="mentionNames"
+        :agent-avatars="agentAvatars"
         :empty-title="groups.topicProtocol ? '开始一个新话题' : '让多个 Agent 一起工作'"
         :empty-description="groups.topicProtocol ? '第一条消息会创建独立话题,各话题分别保留 Agent 上下文。' : '使用 @ 提及指定 Agent,或直接发送消息触发已启用自动回复的成员。'"
         @load-older="groups.loadOlder"
@@ -663,7 +668,7 @@ watch(() => auth.activeProfile?.name, profile => { if (profile) restoreShowThink
         <template #header-actions>
           <div class="group-header-actions">
             <span v-if="hostAgent" class="group-host-chip" :title="`用户未明确 @ 时由 ${hostAgent.displayName || hostAgent.profile} 负责回应`">主持人 {{ hostAgent.displayName || hostAgent.profile }}</span>
-            <div class="group-avatars" aria-label="群聊成员"><span v-for="agent in agents.slice(0, 4)" :key="agent.id" :title="agent.isHost ? `${agent.name} · 主持人` : agent.name" :class="{ host: agent.isHost }">{{ agent.name.slice(0, 1).toUpperCase() }}</span><em v-if="agents.length > 4">+{{ agents.length - 4 }}</em></div>
+            <div class="group-avatars" aria-label="群聊成员"><AgentAvatar v-for="agent in agents.slice(0, 4)" :key="agent.id" :name="agent.name" :avatar="agentAvatars[agent.profile || ''] || ''" :size="24" :title="agent.isHost ? `${agent.name} · 主持人` : agent.name" :class="{ host: agent.isHost }" /><em v-if="agents.length > 4">+{{ agents.length - 4 }}</em></div>
             <button class="icon-button" type="button" title="管理群聊" aria-label="管理群聊" :disabled="!groups.selectedRoom" @click="openSelectedRoomManager"><AppIcon name="dots" /></button>
           </div>
         </template>

+ 5 - 12
src/server/routes.ts

@@ -125,29 +125,22 @@ function normalizedProfiles(value: JsonObject): unknown[] {
 
 function profilesWithHermesBotNames(profiles: unknown[], pluginProfiles: JsonObject | undefined): unknown[] {
   const items = Array.isArray(pluginProfiles?.profiles) ? pluginProfiles.profiles : []
-  const identities = new Map<string, { name?: string; avatar?: string }>()
+  const identities = new Map<string, 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() : ''
-    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 } : {}),
-    })
+    if (name && botName) identities.set(name, botName)
   }
   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 identity = identities.get(name)
-    return identity ? {
+    const agentName = identities.get(name)
+    return agentName ? {
       ...profile,
-      ...(identity.name ? { agentName: identity.name } : {}),
-      ...(identity.avatar ? { agentAvatar: identity.avatar } : {}),
+      agentName,
     } : profile
   })
 }

+ 12 - 0
src/server/websocket.ts

@@ -20,6 +20,7 @@ const CHAT_METHODS = new Set([
   'profiles.configure',
   'profiles.get_asset',
   'profiles.list',
+  'profiles.set_asset',
   'prompt.submit',
   'session.branch',
   'session.close',
@@ -237,6 +238,17 @@ function checkedChatFrame(data: RawData, isBinary: boolean, paired = false): str
       asset: safeString(params.asset, 'profile asset', true)!,
     }
   }
+  if (request.method === 'profiles.set_asset') {
+    const clear = params.clear === true
+    const data = typeof params.data === 'string' ? params.data.trim() : ''
+    if (!clear && !data) throw new HttpError(400, 'profile asset data is required')
+    if (data.length > 2_800_000) throw new HttpError(413, 'profile asset exceeds 2 MiB')
+    params = {
+      name: safeString(params.name, 'profile name', true)!,
+      asset: safeString(params.asset, 'profile asset', true)!,
+      ...(clear ? { clear: true } : { data }),
+    }
+  }
   if (request.method === 'profiles.configure') {
     if (!params.ui_meta || typeof params.ui_meta !== 'object' || Array.isArray(params.ui_meta)) {
       throw new HttpError(400, 'ui_meta must be an object')

+ 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: '竹儿', avatar: 'data:image/png;base64,AA==', agentName: '旧插件名称', isDefault: true }] })
+        return jsonResponse({ profiles: [{ name: 'default', botName: '竹儿', 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: '竹儿', agentAvatar: 'data:image/png;base64,AA==' }],
+      profiles: [{ name: 'default', agentName: '竹儿' }],
       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: '竹儿', agentAvatar: 'data:image/png;base64,AA==' },
+      { name: 'default', is_default: true, agentName: '竹儿' },
     ])
     expect(JSON.stringify(response.body)).not.toContain('旧插件名称')
   })

+ 10 - 1
tests/server/websocket.test.ts

@@ -190,6 +190,11 @@ describe('WebSocket relay', () => {
       },
     }))
     await wsMessage(socket)
+    socket.send(JSON.stringify({
+      id: 'profile-avatar', method: 'profiles.set_asset',
+      params: { name: 'default', asset: 'avatar', data: 'data:image/png;base64,AA==' },
+    }))
+    await wsMessage(socket)
     socket.send(JSON.stringify({
       id: 'hidden-bot', method: 'session.create',
       params: {
@@ -205,7 +210,11 @@ describe('WebSocket relay', () => {
       id: 'profile-meta', method: 'profiles.configure',
       params: { name: 'default' },
     })
-    expect(pairedFrames[2]).toMatchObject({
+    expect(pairedFrames[2]).toEqual({
+      id: 'profile-avatar', method: 'profiles.set_asset',
+      params: { name: 'default', asset: 'avatar', data: 'data:image/png;base64,AA==' },
+    })
+    expect(pairedFrames[3]).toMatchObject({
       id: 'hidden-bot', method: 'session.create',
       params: { profile: 'default', source: 'ios_bot_group', hidden: true },
     })