Browse Source

发布 v0.2.25 自定义动态角色版本

LSamien 2 months ago
parent
commit
5dc413cb6d

+ 1 - 1
README.md

@@ -78,7 +78,7 @@ ID 和包名。浏览器只提交路径和公开元数据;8800 本地校验 RS
 插件安装接口安装。默认源为
 `https://git.samien.cn/samien/hermes-yaoyao.git#hermes-plugins/yaoyao`。
 
-当前发布版本:**Git `v0.2.24` / 夭夭 Web `0.2.24` / Hermes Dashboard 插件 `1.7.3`**。版本组合由仓库根目录的 `release.json` 唯一声明,并由 `npm run release:verify` 校验。
+当前发布版本:**Git `v0.2.25` / 夭夭 Web `0.2.25` / Hermes Dashboard 插件 `1.7.3`**。版本组合由仓库根目录的 `release.json` 唯一声明,并由 `npm run release:verify` 校验。
 
 需要由自动化 Agent 部署或升级时,请直接使用 [Agent 安装手册](docs/agent-install.md)。其中包含固定版本校验、备份、同步、单一 Dashboard 重载与验证步骤。
 

+ 20 - 0
design-qa.md

@@ -76,3 +76,23 @@
 ## Final result
 
 final result: passed
+
+---
+
+# 自有动态 Agent 角色 Design QA(2026-09-02)
+
+## Evidence
+
+- Source visual language: `/Users/samien/git/OpenMausBot/docs/screenshots/agent-profile-desktop.png`.
+- Implementation screenshot: `/Users/samien/.codex/visualizations/2026/09/02/01a05f7c-9ccc-76f2-99f3-116cd8ca64cb/agent-identity/web-desktop.jpg`.
+- Browser viewport: `1440 × 1024`; fixture: `/?fixture=agent-identity`.
+
+## Review
+
+- OpenMausBot 的渐变、白色眼睛、极简嘴型与状态动效语言得到保留;原细长指针轮廓未复制。
+- 圆形、圆角方形和短三角使用统一视觉面积;短三角接近 1:1 包围盒,尖端未超出同组卡片的光学边界。
+- `working` 的实时 transform 在采样间变化;`notifying` 首屏截图可见提醒光环;页面控制台无 warning/error。
+- 角色名称、三种形状、工作、新消息和等待操作均有可访问名称;减少动态效果由 CSS media query 覆盖。
+- 无剩余 P0、P1 或 P2 视觉问题。
+
+final result: passed

+ 5 - 5
docs/agent-install.md

@@ -6,8 +6,8 @@
 
 | 项目 | 版本 | 校验位置 |
 | --- | --- | --- |
-| Git 发布标签 | `v0.2.24` | `release.json` 的 `gitTag` 与 `git describe --tags --exact-match HEAD` |
-| 夭夭 Web | `0.2.24` | `release.json` 与 `package.json` 的 `version` |
+| Git 发布标签 | `v0.2.25` | `release.json` 的 `gitTag` 与 `git describe --tags --exact-match HEAD` |
+| 夭夭 Web | `0.2.25` | `release.json` 与 `package.json` 的 `version` |
 | Hermes Dashboard 插件 | `1.7.3` | `hermes-plugins/yaoyao/dashboard/manifest.json` 的 `version` |
 
 以下步骤以仓库根目录为工作目录。不要使用浮动分支替代发布标签。
@@ -78,12 +78,12 @@ curl --fail --silent --show-error http://127.0.0.1:9119/api/auth/providers
 ## 1. 获取并校验指定发布版本
 
 ```bash
-RELEASE_VERSION=v0.2.24
+RELEASE_VERSION=v0.2.25
 git fetch --tags
 git checkout "$RELEASE_VERSION"
 
 test "$(git describe --tags --exact-match HEAD)" = "$RELEASE_VERSION"
-test "$(node -p \"require('./package.json').version\")" = "0.2.24"
+test "$(node -p \"require('./package.json').version\")" = "0.2.25"
 test "$(node -e \"console.log(require('./hermes-plugins/yaoyao/dashboard/manifest.json').version)\")" = "1.7.3"
 npm run release:verify
 ```
@@ -247,7 +247,7 @@ Git、发布目录、命令输出或日志。配置后检查系统管理中的 A
 
 ## 7. 通过受管服务进行配套升级
 
-首次按第 6 步安装 Web `0.2.24` 后,可在左下角 Agent 菜单打开“系统更新”。
+首次按第 6 步安装 Web `0.2.25` 后,可在左下角 Agent 菜单打开“系统更新”。
 检查更新只读取固定的 `HERMES_YAOYAO_RELEASE_SOURCE`,并锁定最新 `vX.Y.Z`
 标签解析到的 Git 提交;执行升级前仍会检查插件持久目录是否安全。
 

+ 2 - 2
package-lock.json

@@ -1,12 +1,12 @@
 {
   "name": "hermes-yaoyao",
-  "version": "0.2.24",
+  "version": "0.2.25",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "hermes-yaoyao",
-      "version": "0.2.24",
+      "version": "0.2.25",
       "license": "BSL-1.1",
       "dependencies": {
         "@koa/bodyparser": "^5.0.0",

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "hermes-yaoyao",
-  "version": "0.2.24",
+  "version": "0.2.25",
   "private": true,
   "license": "BSL-1.1",
   "description": "夭夭 Web:直连 Hermes 9119 的聊天、团队、文件库与产物工作台",

+ 3 - 3
release.json

@@ -1,7 +1,7 @@
 {
   "schemaVersion": 1,
-  "releaseVersion": "0.2.24",
-  "webVersion": "0.2.24",
+  "releaseVersion": "0.2.25",
+  "webVersion": "0.2.25",
   "pluginVersion": "1.7.3",
-  "gitTag": "v0.2.24"
+  "gitTag": "v0.2.25"
 }

+ 8 - 2
src/client/App.vue

@@ -10,6 +10,7 @@ import { useGroupsStore } from '@/stores/groups'
 import { useKanbanStore } from '@/stores/kanban'
 import { useThemeStore } from '@/stores/theme'
 import { reconcileYaoyaoPlugin } from '@/api/systemUpdate'
+import AgentIdentityFixture from '@/components/app/AgentIdentityFixture.vue'
 
 const auth = useAuthStore()
 const chat = useChatStore()
@@ -17,6 +18,8 @@ const groups = useGroupsStore()
 const kanban = useKanbanStore()
 const theme = useThemeStore()
 const route = useRoute()
+const agentIdentityFixture = import.meta.env.DEV
+  && new URLSearchParams(window.location.search).get('fixture') === 'agent-identity'
 
 const pageTitle = computed(() => {
   const name = route.path.startsWith('/groups')
@@ -44,11 +47,14 @@ watch(() => auth.isAuthenticated, authenticated => {
   }
 })
 
-onMounted(() => auth.bootstrap())
+onMounted(() => {
+  if (!agentIdentityFixture) void auth.bootstrap()
+})
 </script>
 
 <template>
-  <LoadingScreen v-if="auth.status === 'checking'" />
+  <AgentIdentityFixture v-if="agentIdentityFixture" />
+  <LoadingScreen v-else-if="auth.status === 'checking'" />
   <LoginView v-else-if="!auth.isAuthenticated" />
   <PasswordChangeView v-else-if="auth.user?.mustChangePassword" />
   <RouterView v-else />

+ 3 - 3
src/client/api/auth.ts

@@ -1,7 +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'
+import { getProfileIdentities } from './profiles'
 
 function normalizeBootstrap(payload: unknown): BootstrapResponse {
   const root = record(unwrapData(payload as never))
@@ -57,8 +57,8 @@ 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 fetchProfileIdentities(profiles: Profile[]): Promise<Record<string, { displayName: string; avatar: string }>> {
+  return getProfileIdentities(profiles)
 }
 
 export async function fetchCurrentUser(): Promise<CurrentUser | undefined> {

+ 61 - 30
src/client/api/profiles.ts

@@ -2,6 +2,17 @@ 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'
+import {
+  AGENT_MASCOT_EXPRESSIONS,
+  AGENT_MASCOT_SHAPES,
+  YAOYAO_AGENT_IDENTITY_NAMESPACE,
+  agentIdentityFromProfile,
+  agentIdentityMetadata,
+  encodeAgentAvatar,
+  type AgentAvatarMode,
+  type AgentMascotExpression,
+  type AgentMascotShape,
+} from '@shared/agentIdentity'
 
 const MAX_AGENT_NAME_LENGTH = 100
 const MAX_AGENT_AVATAR_LENGTH = 2_800_000
@@ -9,7 +20,10 @@ const avatarDataURLPattern = /^data:image\/(png|jpeg|webp);base64,[A-Za-z0-9+/]+
 
 export type ProfileIdentityInput = {
   title: string
-  /** Undefined means the user did not change the native Desktop avatar. */
+  avatarMode: AgentAvatarMode
+  shape: AgentMascotShape
+  color: string
+  expression: AgentMascotExpression
   avatarDataURL?: string | null
 }
 
@@ -35,13 +49,22 @@ function validateIdentity(input: ProfileIdentityInput): ProfileIdentityInput {
   if (avatarDataURL && (avatarDataURL.length > MAX_AGENT_AVATAR_LENGTH || !avatarDataURLPattern.test(avatarDataURL))) {
     throw new Error('头像必须是小于 2 MB 的 PNG、JPEG 或 WebP 图片')
   }
-  return { title, avatarDataURL }
+  if (!AGENT_MASCOT_SHAPES.includes(input.shape)) throw new Error('请选择有效的头像形状')
+  if (!AGENT_MASCOT_EXPRESSIONS.includes(input.expression)) throw new Error('请选择有效的基础表情')
+  if (!/^#[0-9a-f]{6}$/i.test(input.color)) throw new Error('请选择有效的头像颜色')
+  return {
+    title,
+    avatarMode: input.avatarMode === 'image' && avatarDataURL ? 'image' : 'mascot',
+    shape: input.shape,
+    color: input.color.toLowerCase(),
+    expression: input.expression,
+    avatarDataURL,
+  }
 }
 
 /**
- * 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.
+ * Yaoyao owns this identity in a dedicated Hermes metadata namespace. The
+ * native Desktop Bots title/avatar are deliberately left untouched.
  */
 export async function updateProfileIdentity(profile: Profile, input: ProfileIdentityInput): Promise<void> {
   const identity = validateIdentity(input)
@@ -51,46 +74,54 @@ export async function updateProfileIdentity(profile: Profile, input: ProfileIden
     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 }
     const revisions = jsonObject(current.ui_meta_revisions)
-    const revision = revisions['hermes-bots']
+    const revision = typeof revisions[YAOYAO_AGENT_IDENTITY_NAMESPACE] === 'number'
+      ? revisions[YAOYAO_AGENT_IDENTITY_NAMESPACE] as number
+      : 0
+    const currentIdentity = agentIdentityFromProfile(current)
+    const nextIdentity = {
+      ...currentIdentity,
+      displayName: identity.title,
+      avatarMode: identity.avatarMode,
+      shape: identity.shape,
+      color: identity.color,
+      expression: identity.expression,
+      ...(identity.avatarDataURL ? { imageDataURL: identity.avatarDataURL } : {}),
+    }
+    if (!identity.avatarDataURL) delete nextIdentity.imageDataURL
     await control.request('profiles.configure', {
       name: profile.name,
-      ui_meta: { 'hermes-bots': botMeta },
-      ...(typeof revision === 'number' ? { ui_meta_expected_revisions: { 'hermes-bots': revision } } : {}),
+      ui_meta: {
+        [YAOYAO_AGENT_IDENTITY_NAMESPACE]: agentIdentityMetadata(nextIdentity) as Record<string, JsonValue>,
+      },
+      ui_meta_expected_revisions: {
+        [YAOYAO_AGENT_IDENTITY_NAMESPACE]: 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>> {
+/** Fetch Yaoyao-owned identity for every profile. */
+export async function getProfileIdentities(profiles: Profile[]): Promise<Record<string, { displayName: string; avatar: 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))
+    return Object.fromEntries(
+      readProfileEntries(listed)
+        .filter(item => allowed.has(String(item.name ?? '')))
+        .map(item => {
+          const identity = agentIdentityFromProfile(item)
+          return [String(item.name), {
+            displayName: identity.displayName,
+            avatar: encodeAgentAvatar(identity),
+          }]
+        }),
+    )
   } finally {
     control.close()
   }

+ 2 - 1
src/client/components/app/AgentIdentityDialog.vue

@@ -3,11 +3,12 @@ import { ref, watch } from 'vue'
 import type { Profile } from '@shared/types'
 import AppIcon from '@/components/common/AppIcon.vue'
 import AgentIdentityPanel from './AgentIdentityPanel.vue'
+import type { ProfileIdentityInput } from '@/api/profiles'
 import ModelServicesPanel from './ModelServicesPanel.vue'
 import DuplexVoicePanel from './DuplexVoicePanel.vue'
 
 const props = defineProps<{ open: boolean; profile?: Profile; busy?: boolean; error?: string; isAdmin?: boolean }>()
-const emit = defineEmits<{ close: []; save: [input: { title: string; avatarDataURL?: string | null }] }>()
+const emit = defineEmits<{ close: []; save: [input: ProfileIdentityInput] }>()
 
 const activeTab = ref<'identity' | 'models' | 'voice'>('identity')
 

+ 41 - 0
src/client/components/app/AgentIdentityFixture.vue

@@ -0,0 +1,41 @@
+<script setup lang="ts">
+import AgentAvatar from '@/components/common/AgentAvatar.vue'
+
+const shapes = [
+  { name: '圆圆', avatar: 'yaoyao-mascot:v1:circle:0ea5c6:friendly' },
+  { name: '方方', avatar: 'yaoyao-mascot:v1:square:8057c8:friendly' },
+  { name: '小三角', avatar: 'yaoyao-mascot:v1:triangle:009957:curious' },
+] as const
+
+const states = [
+  { name: '工作中', detail: '状态驱动的眼睛和身体动作', avatar: 'yaoyao-mascot:v1:triangle:377fe6:focused', state: 'working' },
+  { name: '有新消息', detail: '头像弹跳并提醒', avatar: 'yaoyao-mascot:v1:triangle:d94b52:curious', state: 'notifying' },
+  { name: '等待你操作', detail: '状态驱动的眼睛和身体动作', avatar: 'yaoyao-mascot:v1:square:377fe6:curious', state: 'waiting' },
+] as const
+</script>
+
+<template>
+  <main class="identity-fixture">
+    <header>
+      <span>跨端角色系统</span>
+      <h1>自定义 Agent 角色</h1>
+      <p>三种形状保持统一视觉面积;短三角不再使用细长指针轮廓。</p>
+    </header>
+    <section class="shape-row" aria-label="三种头像形状">
+      <article v-for="item in shapes" :key="item.name">
+        <AgentAvatar :name="item.name" :avatar="item.avatar" :size="88" />
+        <strong>{{ item.name }}</strong>
+      </article>
+    </section>
+    <section class="state-list" aria-label="头像状态动画">
+      <article v-for="item in states" :key="item.name">
+        <AgentAvatar :name="item.name" :avatar="item.avatar" :size="58" :state="item.state" />
+        <div><strong>{{ item.name }}</strong><span>{{ item.detail }}</span></div>
+      </article>
+    </section>
+  </main>
+</template>
+
+<style scoped>
+.identity-fixture{min-height:100vh;box-sizing:border-box;padding:72px clamp(28px,7vw,100px);background:var(--canvas);color:var(--text-primary)}header{max-width:850px}header>span{color:var(--accent);font-size:13px;font-weight:750;letter-spacing:.12em;text-transform:uppercase}h1{margin:13px 0 12px;font-size:clamp(38px,5vw,68px);letter-spacing:-.045em;line-height:1.02}header p{max-width:720px;margin:0;color:var(--text-muted);font-size:18px;line-height:1.65}.shape-row{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));max-width:760px;gap:28px;margin-top:60px}.shape-row article{display:grid;place-items:center;gap:16px;min-height:160px;border:1px solid var(--line);border-radius:22px;background:var(--surface-raised)}.shape-row strong{font-size:14px}.state-list{display:grid;max-width:760px;gap:14px;margin-top:28px}.state-list article{display:flex;align-items:center;gap:20px;padding:19px 24px;border:1px solid var(--line);border-radius:20px;background:var(--surface-soft)}.state-list div{display:grid;gap:5px}.state-list strong{font-size:18px}.state-list span{color:var(--text-muted);font-size:13px}@media(max-width:620px){.identity-fixture{padding:44px 22px}.shape-row{gap:10px}.shape-row article{min-height:132px}.state-list article{padding:16px}}
+</style>

File diff suppressed because it is too large
+ 110 - 16
src/client/components/app/AgentIdentityPanel.vue


+ 2 - 1
src/client/components/app/SettingsCenterDialog.vue

@@ -10,6 +10,7 @@ import SystemManagementPanel from '@/components/app/SystemManagementPanel.vue'
 import SystemOverviewPanel from '@/components/app/SystemOverviewPanel.vue'
 import SystemUpdatePanel from '@/components/app/SystemUpdatePanel.vue'
 import AgentAvatar from '@/components/common/AgentAvatar.vue'
+import type { ProfileIdentityInput } from '@/api/profiles'
 import AppIcon from '@/components/common/AppIcon.vue'
 
 type SettingsPage =
@@ -72,7 +73,7 @@ const emit = defineEmits<{
   close: []
   logout: []
   'select-profile': [profile: string]
-  'save-identity': [input: { title: string; avatarDataURL?: string | null }]
+  'save-identity': [input: ProfileIdentityInput]
   'set-theme': [theme: ThemePreference]
 }>()
 

+ 2 - 1
src/client/components/app/WorkspaceShell.vue

@@ -3,6 +3,7 @@ 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 type { ProfileIdentityInput } from '@/api/profiles'
 import AppIcon from '@/components/common/AppIcon.vue'
 import BrandMark from '@/components/common/BrandMark.vue'
 import SettingsCenterDialog from '@/components/app/SettingsCenterDialog.vue'
@@ -80,7 +81,7 @@ const emit = defineEmits<{
   toggleTheme: []
   setTheme: [theme: 'light' | 'dark' | 'system']
   selectProfile: [profile: string]
-  saveIdentity: [input: { title: string; avatarDataURL?: string | null }]
+  saveIdentity: [input: ProfileIdentityInput]
   closeInspector: []
 }>()
 

+ 70 - 10
src/client/components/common/AgentAvatar.vue

@@ -1,30 +1,90 @@
 <script setup lang="ts">
-import { computed } from 'vue'
+// Motion language adapted from OpenMausBot CursorAvatar (Apache-2.0).
+// The circle, rounded square and compact triangle silhouettes are Yaoyao-owned.
+import { computed, getCurrentInstance } from 'vue'
+import {
+  decodeAgentMascotAvatar,
+  defaultAgentIdentity,
+  encodeAgentAvatar,
+  isAgentImageAvatar,
+} from '@shared/agentIdentity'
+
+type MascotState = 'idle' | 'working' | 'notifying' | 'waiting' | 'success' | 'failure'
 
 const props = withDefaults(defineProps<{
   name: string
   avatar?: string
   size?: number
-}>(), { avatar: '', size: 32 })
+  state?: MascotState
+}>(), { avatar: '', size: 32, state: 'idle' })
+
+const resolvedAvatar = computed(() => props.avatar || encodeAgentAvatar(defaultAgentIdentity(props.name)))
+const hasImage = computed(() => isAgentImageAvatar(resolvedAvatar.value))
+const mascot = computed(() => decodeAgentMascotAvatar(resolvedAvatar.value)
+  ?? defaultAgentIdentity(props.name))
+const gradientId = `yaoyao-agent-${getCurrentInstance()?.uid ?? 0}`
+
+function mix(hex: string, toward: string, amount: number): string {
+  const source = Number.parseInt(hex.slice(1), 16)
+  const target = Number.parseInt(toward.slice(1), 16)
+  const channel = (shift: number) => Math.round(
+    ((source >> shift) & 0xff) + (((target >> shift) & 0xff) - ((source >> shift) & 0xff)) * amount,
+  )
+  return `#${[channel(16), channel(8), channel(0)].map(value => value.toString(16).padStart(2, '0')).join('')}`
+}
 
-const initials = computed(() => (props.name.trim() || 'A').slice(0, 1).toLocaleUpperCase())
-const hasAvatar = computed(() => /^data:image\/(png|jpeg|webp);base64,/i.test(props.avatar))
+const highlight = computed(() => mix(mascot.value.color, '#ffffff', 0.54))
+const shadow = computed(() => mix(mascot.value.color, '#000000', 0.4))
+const faceShift = computed(() => mascot.value.shape === 'triangle' ? -4 : 0)
+const mouthPath = computed(() => {
+  switch (mascot.value.expression) {
+    case 'focused': return 'M42 61 Q50 58 58 61'
+    case 'curious': return 'M46 61 Q50 64 54 61'
+    case 'calm': return 'M43 60 Q50 61 57 60'
+    default: return 'M41 59 Q50 68 59 59'
+  }
+})
 </script>
 
 <template>
   <span
     class="agent-avatar"
-    :class="{ 'agent-avatar--image': hasAvatar }"
+    :class="[`agent-avatar--${state}`, { 'agent-avatar--image': hasImage }]"
     :style="{ width: `${size}px`, height: `${size}px` }"
+    role="img"
     :aria-label="`${name} 的头像`"
   >
-    <img v-if="hasAvatar" :src="avatar" alt="" />
-    <span v-else>{{ initials }}</span>
+    <img v-if="hasImage" :src="resolvedAvatar" alt="" />
+    <svg v-else viewBox="0 0 100 100" aria-hidden="true">
+      <defs>
+        <linearGradient :id="gradientId" x1="18" y1="12" x2="82" y2="92" gradientUnits="userSpaceOnUse">
+          <stop offset="0" :stop-color="highlight" />
+          <stop offset="0.5" :stop-color="mascot.color" />
+          <stop offset="1" :stop-color="shadow" />
+        </linearGradient>
+      </defs>
+      <g class="agent-avatar__body">
+        <circle v-if="mascot.shape === 'circle'" cx="50" cy="50" r="44" :fill="`url(#${gradientId})`" />
+        <rect v-else-if="mascot.shape === 'square'" x="9" y="9" width="82" height="82" rx="18" :fill="`url(#${gradientId})`" />
+        <path v-else d="M19 18 Q14 13 15 26 L20 76 Q21 88 31 82 L86 57 Q96 52 87 44 L31 17 Q23 13 19 18Z" :fill="`url(#${gradientId})`" />
+        <g class="agent-avatar__face" :transform="`translate(${faceShift} 0)`">
+          <ellipse class="agent-avatar__eye agent-avatar__eye--left" cx="39" cy="43" rx="5.3" ry="8.2" fill="white" />
+          <ellipse class="agent-avatar__eye agent-avatar__eye--right" cx="61" cy="43" rx="5.3" ry="8.2" fill="white" />
+          <path class="agent-avatar__mouth" :d="mouthPath" fill="none" stroke="white" stroke-width="4" stroke-linecap="round" />
+        </g>
+      </g>
+    </svg>
   </span>
 </template>
 
 <style scoped>
-.agent-avatar { display: grid; flex: 0 0 auto; place-items: center; overflow: hidden; border-radius: 50%; background: transparent; color: var(--text-secondary); font-size: calc(v-bind(size) * .36); font-weight: 700; line-height: 1; }
-.agent-avatar.agent-avatar--image { background: transparent; }
-.agent-avatar img { width: 100%; height: 100%; object-fit: cover; }
+.agent-avatar{display:block;position:relative;flex:0 0 auto;overflow:visible;line-height:1}.agent-avatar img,.agent-avatar svg{display:block;width:100%;height:100%;object-fit:cover}.agent-avatar--image img{border-radius:22%}.agent-avatar__body{transform-box:fill-box;transform-origin:center}.agent-avatar__eye{transform-box:fill-box;transform-origin:center;animation:agent-avatar-blink 9s ease-in-out infinite}.agent-avatar__eye--right{animation-delay:32ms}.agent-avatar--working .agent-avatar__body{animation:agent-avatar-working .9s ease-in-out infinite}.agent-avatar--working .agent-avatar__eye{animation-duration:4.1s}.agent-avatar--notifying .agent-avatar__body,.agent-avatar--notifying img{animation:agent-avatar-notifying .7s cubic-bezier(.2,.8,.2,1) 2}.agent-avatar--notifying::after{position:absolute;inset:-3px;border:2px solid v-bind('mascot.color');border-radius:50%;content:"";animation:agent-avatar-ring 1.2s ease-out 2;pointer-events:none}.agent-avatar--waiting .agent-avatar__body{animation:agent-avatar-waiting 1.8s ease-in-out infinite}.agent-avatar--waiting .agent-avatar__eye{animation-duration:3.4s}.agent-avatar--success .agent-avatar__body,.agent-avatar--success img{animation:agent-avatar-success .9s cubic-bezier(.18,.8,.2,1) both}.agent-avatar--failure .agent-avatar__body,.agent-avatar--failure img{animation:agent-avatar-failure .7s ease-in-out both}
+@keyframes agent-avatar-blink{0%,42%,47%,100%{transform:scaleY(1)}44.5%{transform:scaleY(.08)}}
+@keyframes agent-avatar-working{0%,100%{transform:translateY(1px) rotate(-1deg) scale(.99)}50%{transform:translateY(-2px) rotate(1.5deg) scale(1.02)}}
+@keyframes agent-avatar-notifying{0%,100%{transform:translateY(0) rotate(0) scale(1)}25%{transform:translateY(2px) rotate(-3deg) scale(1.08,.92)}55%{transform:translateY(-4px) rotate(3deg) scale(.95,1.08)}78%{transform:translateY(0) rotate(-1deg) scale(1.03,.98)}}
+@keyframes agent-avatar-ring{0%{opacity:.7;transform:scale(.8)}100%{opacity:0;transform:scale(1.28)}}
+@keyframes agent-avatar-waiting{0%,100%{transform:rotate(-2deg) scale(1)}50%{transform:rotate(2deg) scale(1.04)}}
+@keyframes agent-avatar-success{0%,100%{transform:translateY(0) scale(1)}25%{transform:translateY(3px) scale(1.12,.86)}55%{transform:translateY(-6px) scale(.92,1.12)}80%{transform:translateY(0) scale(1.03,.98)}}
+@keyframes agent-avatar-failure{0%,100%{transform:translateX(0) rotate(0)}25%{transform:translateX(-3px) rotate(-4deg)}50%{transform:translateX(3px) rotate(4deg)}75%{transform:translateX(-2px) rotate(-2deg)}}
+@media(prefers-reduced-motion:reduce){.agent-avatar *,.agent-avatar::after{animation:none!important;transition:none!important}}
 </style>

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

@@ -84,6 +84,13 @@ let thinkingStartedAt = 0
 
 const formatTime = formatMessageTime
 
+function avatarState(message: UiMessage): 'idle' | 'working' | 'waiting' | 'failure' {
+  if (message.status === 'streaming') return 'working'
+  if (message.status === 'pending' || message.status === 'unknown-receipt') return 'waiting'
+  if (message.status === 'failed') return 'failure'
+  return 'idle'
+}
+
 function delegationSummary(metadata?: Record<string, unknown>): string {
   const total = Number(metadata?.task_count ?? 0)
   const completed = Number(metadata?.completed_count ?? 0)
@@ -259,7 +266,7 @@ defineExpose({ scrollToMessage, scrollToAnchor, scrollToBottom })
             'message--assistant-anonymous': message.role === 'assistant' && !showAssistantIdentity,
           }]"
         >
-          <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" />
+          <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" :state="avatarState(message)" />
           <div class="message__body">
             <template v-if="message.timelineKind === 'delegation-complete'">
               <details class="delegation-event">

+ 2 - 2
src/client/components/workspace/WorkspaceView.vue

@@ -1,7 +1,7 @@
 <script setup lang="ts">
 import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
 import WorkspaceShell from '@/components/app/WorkspaceShell.vue'
-import { updateProfileIdentity } from '@/api/profiles'
+import { updateProfileIdentity, type ProfileIdentityInput } from '@/api/profiles'
 import { useAuthStore } from '@/stores/auth'
 import { useThemeStore } from '@/stores/theme'
 
@@ -25,7 +25,7 @@ const identityResetVersion = ref(0)
 
 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: ProfileIdentityInput) {
   const profile = auth.activeProfile
   if (!profile) return
   identityBusy.value = true

+ 5 - 2
src/client/stores/auth.ts

@@ -105,10 +105,13 @@ export const useAuthStore = defineStore('auth', () => {
   }
 
   async function refreshProfileAvatars(): Promise<void> {
-    const avatars = await authApi.fetchProfileAvatars(profiles.value)
+    const identities = await authApi.fetchProfileIdentities(profiles.value)
     profiles.value = profiles.value.map(profile => ({
       ...profile,
-      ...(avatars[profile.name] ? { agentAvatar: avatars[profile.name] } : {}),
+      ...(identities[profile.name] ? {
+        agentName: identities[profile.name]!.displayName,
+        agentAvatar: identities[profile.name]!.avatar,
+      } : {}),
     }))
   }
 

+ 9 - 25
src/server/profileIdentities.ts

@@ -2,6 +2,10 @@ import WebSocket from 'ws'
 import type { ServerConfig } from './config.js'
 import { HttpError } from './errors.js'
 import type { UpstreamServiceSession } from './localAuth.js'
+import {
+  agentIdentityFromProfile,
+  encodeAgentAvatar,
+} from '../shared/agentIdentity.js'
 
 export interface RemoteProfileIdentity {
   name: string
@@ -50,7 +54,6 @@ export class UpstreamProfileIdentityService {
         maxPayload: 36 * 1_024 * 1_024,
       })
       const identities = new Map<string, RemoteProfileIdentity>()
-      const avatarRequests = new Map<string, string>()
       let requestedProfiles = false
       let finished = false
       const timeout = setTimeout(() => finishError(new Error('profile identity request timed out')), 30_000)
@@ -93,38 +96,19 @@ export class UpstreamProfileIdentityService {
               ? object(outer.profile) : outer
             const name = typeof profile.name === 'string' ? profile.name.trim() : ''
             if (!name) continue
-            const meta = object(object(profile.ui_meta)['hermes-bots'])
-            const title = [meta.title, profile.display_name, profile.description, name]
-              .find(value => typeof value === 'string' && value.trim()) as string
+            const agentIdentity = agentIdentityFromProfile(profile)
             const identity: RemoteProfileIdentity = {
               name,
-              displayName: title.trim(),
+              displayName: agentIdentity.displayName,
               model: typeof profile.model === 'string' ? profile.model : '',
-              ...(typeof meta.color === 'string' && /^#[0-9a-f]{6}$/i.test(meta.color)
-                ? { color: meta.color } : {}),
+              color: agentIdentity.color,
+              avatar: encodeAgentAvatar(agentIdentity),
             }
             identities.set(name, identity)
-            if (profile.has_avatar === true) {
-              const id = `avatar-${avatarRequests.size}`
-              avatarRequests.set(id, name)
-              send(id, 'profiles.get_asset', { name, asset: 'avatar' })
-            }
           }
-          if (avatarRequests.size === 0) finish()
+          finish()
           return
         }
-        const id = String(frame.id ?? '')
-        const name = avatarRequests.get(id)
-        if (!name) return
-        avatarRequests.delete(id)
-        const data = object(frame.result).data
-        if (typeof data === 'string'
-          && data.length <= 2_800_000
-          && /^data:image\/(?:png|jpeg|webp);base64,[A-Za-z0-9+/]+={0,2}$/i.test(data)) {
-          const identity = identities.get(name)
-          if (identity) identity.avatar = data
-        }
-        if (avatarRequests.size === 0) finish()
       })
       socket.once('error', finishError)
       socket.once('unexpected-response', () => finishError(new Error('Hermes rejected identity socket')))

+ 140 - 0
src/shared/agentIdentity.ts

@@ -0,0 +1,140 @@
+export const YAOYAO_AGENT_IDENTITY_NAMESPACE = 'hermes-yaoyao-identity'
+export const YAOYAO_AGENT_IDENTITY_VERSION = 1
+
+export const AGENT_MASCOT_SHAPES = ['circle', 'square', 'triangle'] as const
+export type AgentMascotShape = typeof AGENT_MASCOT_SHAPES[number]
+
+export const AGENT_MASCOT_EXPRESSIONS = ['friendly', 'focused', 'curious', 'calm'] as const
+export type AgentMascotExpression = typeof AGENT_MASCOT_EXPRESSIONS[number]
+
+export const AGENT_MASCOT_COLORS = [
+  '#009957', '#377fe6', '#d94b52', '#e78531', '#8057c8',
+  '#0ea5c6', '#d84f8b', '#d8a729', '#01a492', '#e5634e',
+] as const
+
+export type AgentAvatarMode = 'mascot' | 'image'
+
+export interface AgentIdentity {
+  version: 1
+  displayName: string
+  avatarMode: AgentAvatarMode
+  shape: AgentMascotShape
+  color: string
+  expression: AgentMascotExpression
+  imageDataURL?: string
+}
+
+export interface AgentIdentityProfileSource {
+  name?: unknown
+  display_name?: unknown
+  displayName?: unknown
+  description?: unknown
+  ui_meta?: unknown
+}
+
+const imageDataURLPattern = /^data:image\/(png|jpeg|webp);base64,[A-Za-z0-9+/]+={0,2}$/i
+const mascotAvatarPattern = /^yaoyao-mascot:v1:(circle|square|triangle):([0-9a-f]{6}):(friendly|focused|curious|calm)$/i
+
+function object(value: unknown): Record<string, unknown> {
+  return value && typeof value === 'object' && !Array.isArray(value)
+    ? value as Record<string, unknown>
+    : {}
+}
+
+function cleanName(value: unknown): string {
+  return typeof value === 'string'
+    ? value.trim().replace(/\s+/g, ' ').slice(0, 100)
+    : ''
+}
+
+function stableIndex(value: string, modulo: number): number {
+  let hash = 2166136261
+  for (const byte of new TextEncoder().encode(value || 'default')) {
+    hash ^= byte
+    hash = Math.imul(hash, 16777619) >>> 0
+  }
+  return hash % modulo
+}
+
+export function defaultAgentIdentity(profileName: string, displayName?: string): AgentIdentity {
+  const key = profileName.trim() || 'default'
+  return {
+    version: YAOYAO_AGENT_IDENTITY_VERSION,
+    displayName: cleanName(displayName) || key,
+    avatarMode: 'mascot',
+    shape: AGENT_MASCOT_SHAPES[stableIndex(`${key}:shape`, AGENT_MASCOT_SHAPES.length)]!,
+    color: AGENT_MASCOT_COLORS[stableIndex(`${key}:color`, AGENT_MASCOT_COLORS.length)]!,
+    expression: AGENT_MASCOT_EXPRESSIONS[stableIndex(`${key}:expression`, AGENT_MASCOT_EXPRESSIONS.length)]!,
+  }
+}
+
+export function agentIdentityFromProfile(source: AgentIdentityProfileSource): AgentIdentity {
+  const profileName = cleanName(source.name) || 'default'
+  const uiMeta = object(source.ui_meta)
+  const current = object(uiMeta[YAOYAO_AGENT_IDENTITY_NAMESPACE])
+  const legacy = object(uiMeta['hermes-bots'])
+  const inheritedName = cleanName(current.display_name)
+    || cleanName(legacy.title)
+    || cleanName(source.display_name)
+    || cleanName(source.displayName)
+    || cleanName(source.description)
+    || profileName
+  const fallback = defaultAgentIdentity(profileName, inheritedName)
+  const shape = AGENT_MASCOT_SHAPES.includes(current.shape as AgentMascotShape)
+    ? current.shape as AgentMascotShape
+    : fallback.shape
+  const expression = AGENT_MASCOT_EXPRESSIONS.includes(current.expression as AgentMascotExpression)
+    ? current.expression as AgentMascotExpression
+    : fallback.expression
+  const color = typeof current.color === 'string' && /^#[0-9a-f]{6}$/i.test(current.color)
+    ? current.color.toLowerCase()
+    : fallback.color
+  const imageDataURL = typeof current.image_data_url === 'string'
+    && current.image_data_url.length <= 2_800_000
+    && imageDataURLPattern.test(current.image_data_url)
+    ? current.image_data_url
+    : undefined
+  const avatarMode: AgentAvatarMode = current.avatar_mode === 'image' && imageDataURL
+    ? 'image'
+    : 'mascot'
+  return {
+    version: YAOYAO_AGENT_IDENTITY_VERSION,
+    displayName: inheritedName,
+    avatarMode,
+    shape,
+    color,
+    expression,
+    ...(imageDataURL ? { imageDataURL } : {}),
+  }
+}
+
+export function agentIdentityMetadata(identity: AgentIdentity): Record<string, unknown> {
+  return {
+    version: YAOYAO_AGENT_IDENTITY_VERSION,
+    display_name: cleanName(identity.displayName),
+    avatar_mode: identity.avatarMode,
+    shape: identity.shape,
+    color: identity.color.toLowerCase(),
+    expression: identity.expression,
+    ...(identity.imageDataURL ? { image_data_url: identity.imageDataURL } : {}),
+  }
+}
+
+export function encodeAgentAvatar(identity: AgentIdentity): string {
+  if (identity.avatarMode === 'image' && identity.imageDataURL) return identity.imageDataURL
+  return `yaoyao-mascot:v1:${identity.shape}:${identity.color.replace('#', '').toLowerCase()}:${identity.expression}`
+}
+
+export function decodeAgentMascotAvatar(value: string | undefined): Pick<AgentIdentity, 'shape' | 'color' | 'expression'> | null {
+  const match = value?.match(mascotAvatarPattern)
+  if (!match) return null
+  return {
+    shape: match[1]!.toLowerCase() as AgentMascotShape,
+    color: `#${match[2]!.toLowerCase()}`,
+    expression: match[3]!.toLowerCase() as AgentMascotExpression,
+  }
+}
+
+export function isAgentImageAvatar(value: string | undefined): boolean {
+  return Boolean(value && imageDataURLPattern.test(value))
+}

+ 16 - 2
tests/client/agentAvatar.test.ts

@@ -13,12 +13,26 @@ describe('AgentAvatar', () => {
     expect(wrapper.find('img').attributes('src')).toBe(transparentPng)
   })
 
-  it('keeps the caller background available for the text fallback', () => {
+  it('renders a stable animated mascot when no image is configured', () => {
     const wrapper = mount(AgentAvatar, { props: { name: '丫头' } })
 
     expect(wrapper.classes()).not.toContain('agent-avatar--image')
     expect(wrapper.find('img').exists()).toBe(false)
-    expect(wrapper.text()).toBe('丫')
+    expect(wrapper.find('svg').exists()).toBe(true)
+    expect(wrapper.findAll('.agent-avatar__eye')).toHaveLength(2)
+  })
+
+  it('uses the compact triangle and exposes the notifying motion state', () => {
+    const wrapper = mount(AgentAvatar, {
+      props: {
+        name: 'Scout',
+        avatar: 'yaoyao-mascot:v1:triangle:0ea5c6:curious',
+        state: 'notifying',
+      },
+    })
+
+    expect(wrapper.classes()).toContain('agent-avatar--notifying')
+    expect(wrapper.find('svg path[fill^="url"]').exists()).toBe(true)
   })
 
   it('keeps a group-message image transparent when MessageTimeline adds its fallback class', () => {

+ 1 - 1
tests/client/agentManagementDialog.test.ts

@@ -30,7 +30,7 @@ describe('Agent management dialog', () => {
   it('keeps non-admin users on identity management only', async () => {
     const wrapper = mount(AgentIdentityDialog, { attachTo: document.body, props: { open: true, profile, isAdmin: false } })
     await nextTick()
-    expect(document.querySelector('[aria-label="Agent 管理"]')?.textContent).toContain('Bots 头像')
+    expect(document.querySelector('[aria-label="Agent 管理"]')?.textContent).toContain('自定义角色')
     expect(document.querySelector('.management-tabs')).toBeNull()
     expect(document.body.textContent).not.toContain('模型服务')
     expect(document.body.textContent).toContain('custom:tingly / omni')

+ 48 - 0
tests/shared/agentIdentity.test.ts

@@ -0,0 +1,48 @@
+import { describe, expect, it } from 'vitest'
+import {
+  YAOYAO_AGENT_IDENTITY_NAMESPACE,
+  agentIdentityFromProfile,
+  decodeAgentMascotAvatar,
+  defaultAgentIdentity,
+  encodeAgentAvatar,
+} from '../../src/shared/agentIdentity'
+
+describe('Yaoyao agent identity', () => {
+  it('inherits the legacy Bots title once but never its avatar asset', () => {
+    const identity = agentIdentityFromProfile({
+      name: 'default',
+      ui_meta: { 'hermes-bots': { title: '竹儿' } },
+    })
+
+    expect(identity.displayName).toBe('竹儿')
+    expect(identity.avatarMode).toBe('mascot')
+    expect(encodeAgentAvatar(identity)).toMatch(/^yaoyao-mascot:v1:/)
+  })
+
+  it('reads the independent Yaoyao namespace ahead of legacy data', () => {
+    const identity = agentIdentityFromProfile({
+      name: 'researcher',
+      ui_meta: {
+        'hermes-bots': { title: '旧名称' },
+        [YAOYAO_AGENT_IDENTITY_NAMESPACE]: {
+          version: 1,
+          display_name: '新名称',
+          avatar_mode: 'mascot',
+          shape: 'triangle',
+          color: '#0ea5c6',
+          expression: 'curious',
+        },
+      },
+    })
+
+    expect(identity.displayName).toBe('新名称')
+    expect(decodeAgentMascotAvatar(encodeAgentAvatar(identity))).toEqual({
+      shape: 'triangle', color: '#0ea5c6', expression: 'curious',
+    })
+  })
+
+  it('derives the same default shape and color for the same profile', () => {
+    expect(defaultAgentIdentity('designer')).toEqual(defaultAgentIdentity('designer'))
+    expect(defaultAgentIdentity('designer')).not.toEqual(defaultAgentIdentity('reviewer'))
+  })
+})

Some files were not shown because too many files changed in this diff