Przeglądaj źródła

支持账号头像同步并随机生成新 Agent 头像

LSamien 1 miesiąc temu
rodzic
commit
f9bb5be5f6

+ 1 - 1
README.md

@@ -1,6 +1,6 @@
 # 夭夭 Web
 
-当前发布版本:**v0.3.17**,配套 iOS **1.3(176)**。详见 [发布说明](docs/releases/v0.3.17.md)。
+当前发布版本:**v0.3.18**,配套 iOS **1.3(177)**。详见 [发布说明](docs/releases/v0.3.18.md)。
 
 夭夭 Web 为网页和 iOS 提供统一的 Agent、群聊、文件库及语音配置服务。Hermes 通过标准 9119 HTTP/WebSocket 接口执行任务;无需安装 Yaoyao 插件。
 

+ 10 - 0
docs/releases/v0.3.18.md

@@ -0,0 +1,10 @@
+# v0.3.18
+
+账号头像与新建 Agent 的默认头像规则在 Web 和 iOS 对齐。
+
+- 登录账号默认显示用户名首字母;可在 Web 的“登录与安全”上传图片或恢复首字母,iOS 读取同一账号头像并显示,但不提供修改入口。
+- 账号头像保存在 8800 用户资料中,沿用登录鉴权、CSRF、图片类型和大小限制,不影响密码及 Agent 身份。
+- 新建 Agent 未主动选择头像时,由创建流程生成并保存随机 v2 内置头像;颜色、形状/造型和基础表情均参与随机。
+- Web、iOS 创建界面进入时即可看到本次随机结果;服务端同时兜底处理未携带头像的客户端请求。
+
+配套 iOS 为 1.3(177),无需插件升级。

+ 2 - 2
package-lock.json

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

+ 1 - 1
package.json

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

+ 3 - 3
release.json

@@ -1,6 +1,6 @@
 {
   "schemaVersion": 1,
-  "releaseVersion": "0.3.17",
-  "webVersion": "0.3.17",
-  "gitTag": "v0.3.17"
+  "releaseVersion": "0.3.18",
+  "webVersion": "0.3.18",
+  "gitTag": "v0.3.18"
 }

+ 1 - 0
scripts/seed-avatar-fixture.mjs

@@ -21,6 +21,7 @@ const source = (await request('/api/app/agents/sources')).sources[0]
 if (!source) throw new Error('No isolated fixture profile')
 const agents = (await request('/api/app/agents')).agents
 const photo = 'data:image/png;base64,' + readFileSync(new URL('../public/brand/AppIcon-1024.png', import.meta.url)).toString('base64')
+await request('/api/app/account/avatar', { avatar: photo }, 'PUT')
 for (const [name, image] of [['跨端造型', false], ['跨端照片', true]]) {
   const avatar = 'yaoyao-avatar:v2:' + JSON.stringify({
     version: 2, avatarMode: image ? 'image' : 'mascot', shape: 'circle', color: '#1488ff',

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

@@ -76,3 +76,10 @@ export async function changeCredentials(input: {
   })))
   return normalizeUser(payload.user ?? payload)
 }
+
+export async function updateAccountAvatar(avatar: string | null): Promise<CurrentUser> {
+  const payload = record(unwrapData(await apiRequest<unknown>('/api/app/account/avatar', {
+    method: 'PUT', body: { avatar } as unknown as JsonValue,
+  })))
+  return normalizeUser(payload.user ?? payload)
+}

+ 74 - 1
src/client/components/app/AccountSecurityPanel.vue

@@ -1,6 +1,7 @@
 <script setup lang="ts">
 import { computed, ref, watch } from 'vue'
 import AppIcon from '@/components/common/AppIcon.vue'
+import AccountInitialAvatar from '@/components/common/AccountInitialAvatar.vue'
 import { useAuthStore } from '@/stores/auth'
 
 const props = withDefaults(defineProps<{
@@ -31,6 +32,8 @@ const confirmation = ref('')
 const busy = ref(false)
 const error = ref('')
 const notice = ref('')
+const avatarInput = ref<HTMLInputElement>()
+const avatarBusy = ref(false)
 
 const isAdmin = computed(() => auth.user?.role === 'admin')
 const isActive = computed(() => props.open ?? props.active)
@@ -95,6 +98,67 @@ async function save() {
   }
 }
 
+function imageUrl(file: File): Promise<string> {
+  return new Promise((resolve, reject) => {
+    const reader = new FileReader()
+    reader.onload = () => typeof reader.result === 'string' ? resolve(reader.result) : reject(new Error('无法读取图片'))
+    reader.onerror = () => reject(new Error('无法读取图片'))
+    reader.readAsDataURL(file)
+  })
+}
+
+function resizeImage(source: string): Promise<string> {
+  return new Promise((resolve, reject) => {
+    const image = new Image()
+    image.onload = () => {
+      const ratio = Math.min(1, 256 / Math.max(image.width, image.height))
+      const canvas = document.createElement('canvas')
+      canvas.width = Math.max(1, Math.round(image.width * ratio))
+      canvas.height = Math.max(1, Math.round(image.height * ratio))
+      const context = canvas.getContext('2d')
+      if (!context) return reject(new Error('当前浏览器不支持图片处理'))
+      context.drawImage(image, 0, 0, canvas.width, canvas.height)
+      resolve(canvas.toDataURL('image/png'))
+    }
+    image.onerror = () => reject(new Error('请选择有效的 PNG、JPEG 或 WebP 图片'))
+    image.src = source
+  })
+}
+
+async function chooseAvatar(event: Event) {
+  const input = event.target as HTMLInputElement
+  const file = input.files?.[0]
+  if (!file) return
+  avatarBusy.value = true
+  error.value = ''
+  notice.value = ''
+  try {
+    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')
+    await auth.updateAccountAvatar(await resizeImage(await imageUrl(file)))
+    notice.value = '账号头像已更新,iOS 下次打开 Bot 模式时会同步显示'
+  } catch (cause) {
+    error.value = cause instanceof Error ? cause.message : '保存头像失败'
+  } finally {
+    avatarBusy.value = false
+    input.value = ''
+  }
+}
+
+async function resetAvatar() {
+  avatarBusy.value = true
+  error.value = ''
+  notice.value = ''
+  try {
+    await auth.updateAccountAvatar(null)
+    notice.value = '已恢复首字母头像'
+  } catch (cause) {
+    error.value = cause instanceof Error ? cause.message : '恢复头像失败'
+  } finally {
+    avatarBusy.value = false
+  }
+}
+
 watch(isActive, active => {
   if (active) reset()
 }, { immediate: true })
@@ -114,6 +178,14 @@ watch(canSave, value => emit('can-save-change', value), { immediate: true, flush
       <span class="account-badge">{{ auth.user?.username || '当前账号' }}</span>
     </header>
 
+    <section class="account-avatar-card" aria-label="账号头像">
+      <AccountInitialAvatar :name="auth.user?.username || '当前账号'" :image-url="auth.user?.avatar" :size="64" />
+      <div><strong>账号头像</strong><small>默认显示用户名首字母。头像只在 Web 修改,iOS 会同步显示。</small></div>
+      <button type="button" :disabled="busy || avatarBusy" @click="avatarInput?.click()">{{ avatarBusy ? '正在保存…' : '更换头像' }}</button>
+      <button v-if="auth.user?.avatar" type="button" :disabled="busy || avatarBusy" @click="resetAvatar">使用首字母</button>
+      <input ref="avatarInput" class="sr-only" type="file" accept="image/png,image/jpeg,image/webp" @change="chooseAvatar" />
+    </section>
+
     <form :id="formId" class="security-form" @submit.prevent="save">
       <h3 v-if="isAdmin" class="security-section-title">账号信息</h3>
       <label v-if="isAdmin" class="field">
@@ -178,6 +250,7 @@ watch(canSave, value => emit('can-save-change', value), { immediate: true, flush
 .panel-heading h2{margin:0;font-size:22px;letter-spacing:-.035em}
 .panel-description{max-width:520px;margin:8px 0 0;color:var(--text-secondary);font-size:13px;line-height:1.65}
 .account-badge{max-width:180px;padding:6px 10px;overflow:hidden;border:1px solid var(--line);border-radius:999px;background:var(--surface-soft);color:var(--text-muted);font-size:12px;text-overflow:ellipsis;white-space:nowrap}
+.account-avatar-card{display:flex;align-items:center;gap:12px}.account-avatar-card>div{display:grid;min-width:0;flex:1;gap:4px}.account-avatar-card strong{font-size:14px}.account-avatar-card small{color:var(--text-muted);font-size:12px;line-height:1.5}.account-avatar-card button{min-height:36px;padding:0 11px;border:1px solid var(--line);border-radius:9px;background:var(--surface-raised);color:var(--text-secondary);cursor:pointer;font-size:12px;font-weight:600}.account-avatar-card button:disabled{cursor:wait;opacity:.5}.sr-only{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
 .security-form{display:grid;gap:20px}
 .security-section-title{margin:0;font-size:16px}.security-section-title--password{margin-top:8px;padding-top:24px;border-top:1px solid var(--line)}
 .field{display:grid;gap:8px;color:var(--text-secondary);font-size:14px;font-weight:650}
@@ -200,5 +273,5 @@ watch(canSave, value => emit('can-save-change', value), { immediate: true, flush
 .logout-card p{max-width:470px;margin:5px 0 0;color:var(--text-muted);font-size:12px;line-height:1.55}
 .logout-button{display:inline-flex;min-height:40px;flex:0 0 auto;align-items:center;justify-content:center;gap:7px;padding:0 12px;border:1px solid color-mix(in srgb,var(--danger) 35%,var(--line));border-radius:9px;background:var(--surface-raised);color:var(--danger);cursor:pointer;font-size:13px;font-weight:650}
 .logout-button:disabled{cursor:wait;opacity:.5}
-@media(max-width:620px){.panel-heading{align-items:flex-start;flex-direction:column;gap:10px}.form-actions,.logout-card{align-items:stretch;flex-direction:column}.form-actions .solid-button,.logout-button{width:100%}}
+@media(max-width:620px){.panel-heading{align-items:flex-start;flex-direction:column;gap:10px}.account-avatar-card{align-items:flex-start;flex-wrap:wrap}.account-avatar-card>div{min-width:calc(100% - 84px)}.form-actions,.logout-card{align-items:stretch;flex-direction:column}.form-actions .solid-button,.logout-button{width:100%}}
 </style>

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

@@ -44,6 +44,7 @@ const props = withDefaults(defineProps<{
   botMode?: boolean
   initialPage?: SettingsPage
   userName?: string
+  userAvatar?: string
   pairingUserName?: string
   activeProfile?: Profile
   profiles?: Profile[]
@@ -59,6 +60,7 @@ const props = withDefaults(defineProps<{
 }>(), {
   initialPage: 'agent-identity',
   userName: '',
+  userAvatar: '',
   pairingUserName: '',
   activeProfile: undefined,
   profiles: () => [],
@@ -289,7 +291,7 @@ function requestModeSwitch() {
                   </div>
                 </div>
                 <div v-else class="settings-account-summary" aria-label="当前账号">
-                  <AccountInitialAvatar :name="accountName" :size="34" />
+                  <AccountInitialAvatar :name="accountName" :image-url="userAvatar" :size="34" />
                   <span><strong>{{ accountName }}</strong><small>当前账号</small></span>
                 </div>
 

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

@@ -38,6 +38,7 @@ const SIDEBAR_SEARCH_CLOSE_EVENT = 'hermes-yaoyao:sidebar-search-close'
 
 const props = withDefaults(defineProps<{
   userName?: string
+  userAvatar?: string
   pairingUserName?: string
   activeProfile?: Profile
   profiles?: Profile[]
@@ -58,6 +59,7 @@ const props = withDefaults(defineProps<{
   identityResetVersion?: number
 }>(), {
   userName: '',
+  userAvatar: '',
   pairingUserName: '',
   activeProfile: undefined,
   profiles: () => [],
@@ -456,7 +458,7 @@ onBeforeUnmount(() => {
       <div class="sidebar-footer">
         <div class="sidebar-account-switcher">
           <button class="sidebar-account-switcher__main" type="button" :title="applicationWorkspace ? '当前账号' : `切换 Agent:${profileTitle(activeProfile)}`" :aria-haspopup="applicationWorkspace ? undefined : 'listbox'" :aria-expanded="applicationWorkspace ? undefined : profileMenuOpen" @click="applicationWorkspace ? openSettings('account-security', $event) : toggleProfileMenu($event)">
-            <AccountInitialAvatar v-if="applicationWorkspace" :name="userName" :size="30" />
+            <AccountInitialAvatar v-if="applicationWorkspace" :name="userName" :image-url="userAvatar" :size="30" />
             <AgentAvatar v-else :name="profileTitle(activeProfile)" :avatar="activeProfile?.agentAvatar || ''" :size="30" />
             <span class="account-copy">
               <strong>{{ applicationWorkspace ? userName : profileTitle(activeProfile) }}</strong>
@@ -563,7 +565,7 @@ onBeforeUnmount(() => {
       <div class="sidebar-footer mobile-drawer__footer">
         <div class="sidebar-account-switcher">
           <button class="sidebar-account-switcher__main" type="button" :title="applicationWorkspace ? '当前账号' : `切换 Agent:${profileTitle(activeProfile)}`" :aria-haspopup="applicationWorkspace ? undefined : 'listbox'" :aria-expanded="applicationWorkspace ? undefined : profileMenuOpen" @click="applicationWorkspace ? openSettings('account-security', $event) : toggleProfileMenu($event)">
-            <AccountInitialAvatar v-if="applicationWorkspace" :name="userName" :size="30" />
+            <AccountInitialAvatar v-if="applicationWorkspace" :name="userName" :image-url="userAvatar" :size="30" />
             <AgentAvatar v-else :name="profileTitle(activeProfile)" :avatar="activeProfile?.agentAvatar || ''" :size="30" />
             <span class="account-copy">
               <strong>{{ applicationWorkspace ? userName : profileTitle(activeProfile) }}</strong>
@@ -617,6 +619,7 @@ onBeforeUnmount(() => {
       @switch-mode="switchInterfaceMode"
       :initial-page="settingsPage"
       :user-name="userName"
+      :user-avatar="userAvatar"
       :pairing-user-name="pairingUserName"
       :active-profile="activeProfile"
       :profiles="profiles"

+ 4 - 2
src/client/components/common/AccountInitialAvatar.vue

@@ -3,8 +3,9 @@ import { computed } from 'vue'
 
 const props = withDefaults(defineProps<{
   name: string
+  imageUrl?: string
   size?: number
-}>(), { size: 30 })
+}>(), { imageUrl: '', size: 30 })
 
 const initial = computed(() => Array.from(props.name.trim())[0]?.toLocaleUpperCase() || '?')
 const colors = ['#1488ff', '#00a17a', '#9655f7', '#e05d2f', '#b7791f', '#5166d6'] as const
@@ -24,9 +25,10 @@ const background = computed(() => {
     :style="{ width: `${size}px`, height: `${size}px`, backgroundColor: background, fontSize: `${Math.max(11, Math.round(size * .42))}px` }"
     role="img"
     :aria-label="`${name || '当前账号'} 的账号头像`"
-  ><span aria-hidden="true">{{ initial }}</span></span>
+  ><img v-if="imageUrl" :src="imageUrl" alt="" /><span v-else aria-hidden="true">{{ initial }}</span></span>
 </template>
 
 <style scoped>
 .account-initial-avatar { display: inline-grid; flex: 0 0 auto; place-items: center; border-radius: 50%; color: #fff; font-weight: 700; line-height: 1; text-transform: uppercase; user-select: none; }
+.account-initial-avatar img { width: 100%; height: 100%; border-radius: inherit; object-fit: cover; }
 </style>

+ 1 - 0
src/client/components/workspace/WorkspaceView.vue

@@ -57,6 +57,7 @@ onBeforeUnmount(() => window.removeEventListener('focus', refreshOnWindowFocus))
 <template>
   <WorkspaceShell
     :user-name="userName"
+    :user-avatar="auth.user?.avatar"
     :pairing-user-name="auth.user?.username || ''"
     :active-profile="auth.activeProfile"
     :profiles="auth.profiles"

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

@@ -119,10 +119,13 @@ export const useAuthStore = defineStore('auth', () => {
     user.value = await authApi.changeCredentials(input)
     await bootstrap()
   }
+  async function updateAccountAvatar(avatar: string | null): Promise<void> {
+    user.value = await authApi.updateAccountAvatar(avatar)
+  }
 
   return {
     status, user, profiles, activeProfileName, activeProfile, csrfToken, error, authRequired, insecureLan, groupUploadsEnabled,
     upstreamReady, upstreamError, isAuthenticated, bootstrap, login, logout, selectProfile, refreshProfiles, refreshProfileAvatars,
-    changeCredentials, expire,
+    changeCredentials, updateAccountAvatar, expire,
   }
 })

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

@@ -65,6 +65,7 @@ export function normalizeUser(value: unknown): CurrentUser {
   return {
     id,
     username: string(pick(source, 'username', 'display_name', 'displayName', 'email', 'user_id', 'userId'), id),
+    avatar: string(source.avatar) || undefined,
     displayName: string(pick(source, 'display_name', 'displayName')) || undefined,
     email: string(source.email) || undefined,
     provider: string(source.provider) || undefined,

+ 3 - 1
src/client/views/ConversationsView.vue

@@ -26,6 +26,7 @@ import { useAuthStore } from '@/stores/auth'
 import { useThemeStore } from '@/stores/theme'
 import { apiRequest } from '@/api/client'
 import { updateProfileIdentity, type ProfileIdentityInput } from '@/api/profiles'
+import { encodeAgentAvatar, randomAgentIdentity } from '@shared/agentIdentity'
 import type { JsonValue } from '@shared/types'
 import type {
   WorkspaceAgent as Agent,
@@ -278,7 +279,7 @@ async function openDialog(kind: NonNullable<typeof dialog.value>) {
   selectedPresetId.value = 'custom'
   Object.assign(form, {
     name: '',
-    avatar: '',
+    avatar: kind === 'agent' ? encodeAgentAvatar(randomAgentIdentity('new-agent', 'Agent')) : '',
     instructions: '',
     source: '',
     memberIds: [],
@@ -521,6 +522,7 @@ onBeforeUnmount(() => {
 <template>
   <WorkspaceShell
     :user-name="auth.user?.username"
+    :user-avatar="auth.user?.avatar"
     :profiles="auth.profiles"
     :active-profile="auth.activeProfile"
     :is-admin="auth.user?.role === 'admin'"

+ 1 - 1
src/server/app.ts

@@ -269,7 +269,7 @@ export function createApplication(options: ApplicationOptions = {}): Application
     },
   })
   const ordinaryBody = parseBody('2mb'), workspaceBody = parseBody('4mb')
-  app.use((ctx,next) => (/^\/api\/app\/(agents|conversations)(\/|$)/.test(ctx.path) ? workspaceBody : ordinaryBody)(ctx,next))
+  app.use((ctx,next) => (/^\/api\/app\/(agents|conversations)(\/|$)/.test(ctx.path) || ctx.path === '/api/app/account/avatar' ? workspaceBody : ordinaryBody)(ctx,next))
   app.use(async (ctx, next) => {
     ctx.state.localAuth = auth
     ctx.state.upstreamSession = upstreamSession

+ 19 - 0
src/server/localAuth.ts

@@ -18,6 +18,7 @@ import { dirname, join } from 'node:path'
 import type Koa from 'koa'
 import { parse, serialize } from 'cookie'
 import { HttpError } from './errors.js'
+import { validAvatarImage } from '../shared/agentIdentity.js'
 import { appendSetCookies } from './security.js'
 import { CookieJar, type UpstreamRequestOptions, type UpstreamResponse, UpstreamClient } from './upstream.js'
 import { allowsLocalAuthorization, isLocalAuthorizationTarget, localSessionToken } from './loopbackAuthorization.js'
@@ -31,6 +32,7 @@ export type LocalRole = 'admin' | 'user'
 interface StoredUser {
   id: string
   username: string
+  avatar?: string
   normalizedUsername: string
   role: LocalRole
   enabled: boolean
@@ -54,6 +56,7 @@ interface StoredSessions { version: 1; sessions: Array<SessionRecord & { tokenHa
 export interface LocalUser {
   id: string
   username: string
+  avatar?: string
   role: LocalRole
   enabled: boolean
   mustChangePassword: boolean
@@ -88,6 +91,7 @@ function publicUser(user: StoredUser): LocalUser {
   return {
     id: user.id,
     username: user.username,
+    ...(validAvatarImage(user.avatar) ? { avatar: user.avatar } : {}),
     role: user.role,
     enabled: user.enabled,
     mustChangePassword: user.mustChangePassword,
@@ -283,6 +287,21 @@ export class LocalAuthStore {
     return this.login(ctx, username, newPassword)
   }
 
+  setAvatar(ctx: Koa.Context, value: unknown): LocalUser {
+    const current = this.require(ctx)
+    const user = this.#users.find(candidate => candidate.id === current.id)!
+    if (value !== null && value !== '' && !validAvatarImage(value)) {
+      throw new HttpError(400, '头像必须是有效的 PNG、JPEG 或 WebP 图片', 'invalid_account_avatar')
+    }
+    if (typeof value === 'string' && value) user.avatar = value
+    else delete user.avatar
+    user.updatedAt = Date.now()
+    this.#saveUsers()
+    const published = publicUser(user)
+    ctx.state.localUser = published
+    return published
+  }
+
   upstreamCredentials(fallback?: UpstreamCredentials): UpstreamCredentials | undefined {
     try {
       const encoded = Buffer.from(readFileSync(this.#credentialPath, 'utf8').trim(), 'base64url')

+ 6 - 0
src/server/routes.ts

@@ -953,6 +953,7 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
       user_id: user.id,
       display_name: user.username,
       username: user.username,
+      avatar: user.avatar,
       provider: 'yaoyao-local',
       role: user.role,
       must_change_password: user.mustChangePassword,
@@ -1407,6 +1408,11 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
     const user = dependencies.auth.changeCredentials(ctx, currentPassword, newPassword, username)
     json(ctx, 200, { user, mustChangePassword: false, csrfToken: dependencies.csrf.issue(ctx, true) })
   })
+  router.put('/api/app/account/avatar', (ctx) => {
+    const request = body(ctx)
+    const user = dependencies.auth.setAvatar(ctx, request.avatar)
+    json(ctx, 200, { user })
+  })
   router.get('/api/app/admin/users', (ctx) => {
     const admin = dependencies.auth.requireAdmin(ctx)
     json(ctx, 200, { items: dependencies.auth.list(admin) })

+ 2 - 2
src/server/workspaceStore.ts

@@ -6,7 +6,7 @@ import { EventEmitter } from 'node:events'
 import { z } from 'zod'
 import { HttpError } from './errors.js'
 import { notificationPlainText } from './notificationText.js'
-import { decodeAgentMascotAvatar, isAgentImageAvatar, defaultAgentIdentity, encodeAgentAvatar, normalizeAvatar, MAX_AVATAR_DESCRIPTOR_LENGTH } from '../shared/agentIdentity.js'
+import { decodeAgentMascotAvatar, isAgentImageAvatar, defaultAgentIdentity, encodeAgentAvatar, normalizeAvatar, randomAgentIdentity, MAX_AVATAR_DESCRIPTOR_LENGTH } from '../shared/agentIdentity.js'
 import type {
   WorkspaceAgent as Agent,
   WorkspaceConversation as Conversation,
@@ -234,7 +234,7 @@ export class WorkspaceStore {
         id = randomUUID()
       const agent: Agent = {
         ...body,
-        avatar: normalizeAvatar(body.avatar),
+        avatar: body.avatar ? normalizeAvatar(body.avatar) : encodeAgentAvatar(randomAgentIdentity(id, body.name)),
         id,
         archived: false,
         revision: 1,

+ 10 - 0
src/shared/agentIdentity.ts

@@ -37,6 +37,16 @@ export function validAvatarImage(v: unknown): boolean { return typeof v === 'str
 export function defaultAgentIdentity(profile: string, displayName?: string): AgentIdentity {
   return { version: 2, displayName: name(displayName) || name(profile) || 'default', avatarMode: 'mascot', shape: 'circle', color: '#00c875', expression: 'idle', bodyId: null, imageCrop: 'rounded' }
 }
+export function randomAgentIdentity(profile: string, displayName?: string, random: () => number = Math.random): AgentIdentity {
+  const identity = defaultAgentIdentity(profile, displayName)
+  const count = AGENT_MASCOT_SHAPES.length + AGENT_MASCOT_BODIES.length
+  const visual = Math.min(count - 1, Math.max(0, Math.floor(random() * count)))
+  if (visual < AGENT_MASCOT_SHAPES.length) identity.shape = AGENT_MASCOT_SHAPES[visual]!
+  else identity.bodyId = AGENT_MASCOT_BODIES[visual - AGENT_MASCOT_SHAPES.length]!
+  identity.color = AGENT_MASCOT_COLORS[Math.min(AGENT_MASCOT_COLORS.length - 1, Math.max(0, Math.floor(random() * AGENT_MASCOT_COLORS.length)))]!
+  identity.expression = AGENT_MASCOT_EXPRESSIONS[Math.min(AGENT_MASCOT_EXPRESSIONS.length - 1, Math.max(0, Math.floor(random() * AGENT_MASCOT_EXPRESSIONS.length)))]!
+  return identity
+}
 function descriptor(v: Record<string, unknown>): AgentIdentity | null {
   if (v.version !== 2 || !['mascot', 'image'].includes(String(v.avatarMode)) || !AGENT_MASCOT_SHAPES.includes(v.shape as AgentMascotShape)
       || typeof v.color !== 'string' || !/^#[0-9a-f]{6}$/i.test(v.color)

+ 1 - 0
src/shared/types.ts

@@ -12,6 +12,7 @@ export type AuthStatus =
 export interface CurrentUser {
   id: string
   username: string
+  avatar?: string
   displayName?: string
   email?: string
   provider?: string

+ 13 - 1
tests/client/accountSecurityPanel.test.ts

@@ -4,8 +4,9 @@ import AccountSecurityDialog from '@/components/app/AccountSecurityDialog.vue'
 import AccountSecurityPanel from '@/components/app/AccountSecurityPanel.vue'
 
 const auth = vi.hoisted(() => ({
-  user: { id: 'admin-1', username: 'owner', role: 'admin' } as { id: string; username: string; role: string } | undefined,
+  user: { id: 'admin-1', username: 'owner', role: 'admin' } as { id: string; username: string; role: string; avatar?: string } | undefined,
   changeCredentials: vi.fn(async () => undefined),
+  updateAccountAvatar: vi.fn(async (avatar: string | null) => { if (auth.user) { if (avatar) auth.user.avatar=avatar; else delete auth.user.avatar } }),
   logout: vi.fn(async () => undefined),
 }))
 
@@ -18,6 +19,7 @@ async function fill(wrapper: ReturnType<typeof mount>, name: string, value: stri
 beforeEach(() => {
   auth.user = { id: 'admin-1', username: 'owner', role: 'admin' }
   auth.changeCredentials.mockResolvedValue(undefined)
+  auth.updateAccountAvatar.mockClear()
 })
 
 afterEach(() => {
@@ -83,6 +85,16 @@ describe('Account security panel', () => {
     expect(auth.logout).not.toHaveBeenCalled()
   })
 
+  it('restores the account to its generated initial avatar independently', async () => {
+    auth.user = { id: 'admin-1', username: 'owner', role: 'admin', avatar: 'data:image/png;base64,aGVsbG8=' }
+    const wrapper = mount(AccountSecurityPanel, { props: { active: true } })
+    expect(wrapper.get('.account-initial-avatar img').attributes('src')).toBe(auth.user.avatar)
+    await wrapper.get('.account-avatar-card button:last-of-type').trigger('click')
+    await flushPromises()
+    expect(auth.updateAccountAvatar).toHaveBeenCalledWith(null)
+    expect(wrapper.get('.account-initial-avatar').text()).toBe('O')
+  })
+
   it('keeps the legacy dialog wrapper operational', async () => {
     const wrapper = mount(AccountSecurityDialog, {
       attachTo: document.body,

+ 5 - 0
tests/e2e/workspace-chat.spec.ts

@@ -47,6 +47,11 @@ test('created roles and editable teams share a durable chat list without plugin
   const settings = page.getByRole('dialog', { name: '设置中心', exact: true })
   await expect(settings.locator('.settings-account-summary')).toContainText('fixture')
   await expect(settings.locator('.settings-account-summary .account-initial-avatar')).toHaveText('F')
+  await settings.locator('.account-avatar-card input[type=file]').setInputFiles({name:'account.png',mimeType:'image/png',buffer:Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=','base64')})
+  await expect(settings.locator('.settings-account-summary .account-initial-avatar img')).toBeVisible()
+  expect((await (await page.request.get('/api/app/bootstrap')).json()).user.avatar).toMatch(/^data:image\/png;base64,/)
+  await settings.getByRole('button', {name:'使用首字母',exact:true}).click()
+  await expect(settings.locator('.settings-account-summary .account-initial-avatar')).toHaveText('F')
   await settings.getByRole('button', { name: '身份与头像', exact: true }).click()
   await settings.getByRole('button', { name: '造型:星星', exact: true }).click()
   await settings.getByRole('button', { name: '使用颜色 #1488ff', exact: true }).click()

+ 15 - 0
tests/server/localAuth.test.ts

@@ -61,6 +61,21 @@ describe('8800 local users', () => {
     expect(store.list(admin).map(item => item.username)).toEqual(['admin'])
   })
 
+  it('persists an account image without invalidating the existing session', () => {
+    const home = mkdtempSync(join(tmpdir(), 'yaoyao-local-auth-')); homes.push(home)
+    const store = new LocalAuthStore(home), login = context()
+    store.login(login, 'admin', 'admin')
+    const authenticated = context(issuedCookie(login))
+    store.changeCredentials(authenticated, 'admin', 'new-password', 'admin')
+    const active = context(issuedCookie(authenticated))
+    const avatar = 'data:image/png;base64,aGVsbG8='
+    expect(store.setAvatar(active, avatar).avatar).toBe(avatar)
+    expect(store.require(active).avatar).toBe(avatar)
+    expect(new LocalAuthStore(home).require(context(issuedCookie(authenticated))).avatar).toBe(avatar)
+    expect(() => store.setAvatar(active, 'https://example.test/avatar.png')).toThrow(/PNG、JPEG/)
+    expect(store.setAvatar(active, null).avatar).toBeUndefined()
+  })
+
   it('encrypts the 9119 service password at rest', () => {
     const home = mkdtempSync(join(tmpdir(), 'yaoyao-local-auth-'))
     homes.push(home)

+ 8 - 0
tests/server/localAuthRoutes.test.ts

@@ -50,6 +50,14 @@ describe('8800 local authentication routes', () => {
     expect(ready.body.profiles).toHaveLength(1)
     expect(JSON.stringify([ready.body, ready.headers])).not.toContain(token)
     expect(runtime.auth.upstreamCredentials()).toBeUndefined()
+    const avatar = 'data:image/png;base64,aGVsbG8='
+    const avatarUpdate = await agent.put('/api/app/account/avatar').set('Host', '127.0.0.1:8800').set('Origin', origin)
+      .set('X-CSRF-Token', ready.body.csrfToken).send({ avatar }).expect(200)
+    expect(avatarUpdate.body.user.avatar).toBe(avatar)
+    expect((await agent.get('/api/app/bootstrap').set('Host', '127.0.0.1:8800').expect(200)).body.user.avatar).toBe(avatar)
+    expect((await agent.get('/api/auth/me').set('Host', '127.0.0.1:8800').expect(200)).body.avatar).toBe(avatar)
+    await agent.put('/api/app/account/avatar').set('Host', '127.0.0.1:8800').set('Origin', origin)
+      .set('X-CSRF-Token', ready.body.csrfToken).send({ avatar: 'https://example.test/avatar.png' }).expect(400)
     await agent.post('/api/realtime/channels').set('Host', '127.0.0.1:8800').set('Origin', origin).send({ channel: 'chat' }).expect(403)
     await request(runtime.app.callback()).get('/api/realtime/capabilities').set('Host', '127.0.0.1:8800').expect(401)
   })

+ 10 - 2
tests/server/workspace.test.ts

@@ -499,6 +499,14 @@ it('accepts the existing cross-client mascot and team avatar formats', () => {
   expect(() => store.updateAgent(owner, a.id, { avatar: 'javascript:alert(1)' })).toThrow()
 })
 
+it('assigns a random v2 avatar only when a new Agent omits its avatar', () => {
+  const random = vi.spyOn(Math, 'random').mockReturnValue(0)
+  const created = store.createAgent(owner, { name: '随机头像', profile: 'default' })
+  expect(decodeAgentAvatar(created.avatar)).toMatchObject({ shape: 'circle', color: '#000000', expression: 'idle' })
+  expect(created.avatar).not.toBe(encodeAgentAvatar(defaultAgentIdentity(created.id, created.name)))
+  random.mockRestore()
+})
+
 it('keeps the last message time independent of pinning and metadata edits', () => {
   const agent=store.createAgent(owner,{name:'时间验收',profile:'default'})
   const c=store.list<WorkspaceConversation>(owner,'conversation')[0]!
@@ -570,9 +578,9 @@ it('keeps an unconfirmed turn separate from a later reply and retries history wi
   expect(requests.filter(r => r.method === 'prompt.submit')).toHaveLength(1)
   expect(store.messages(owner, c.id).at(-1)).toMatchObject({ content: '本轮结果', status: 'complete' })
 })
-it('uses one canonical default avatar for the agent and its direct conversation',()=>{
+it('uses one generated avatar for the agent and its direct conversation',()=>{
   const a=agent('默认头像'),c=direct(a.id)
-  expect(decodeAgentAvatar(a.avatar)).toMatchObject({version:2,color:'#00c875'})
+  expect(decodeAgentAvatar(a.avatar)).toMatchObject({version:2,avatarMode:'mascot'})
   expect(store.agentSummary(a).avatar).toBe(store.conversationSummary(owner,c).avatar)
   const changed=store.updateAgent(owner,a.id,{name:'换个名称'})
   expect(store.agentSummary(changed).avatar).toBe(store.agentSummary(a).avatar)

+ 7 - 1
tests/shared/agentIdentity.test.ts

@@ -1,5 +1,5 @@
 import {describe,expect,it} from 'vitest'
-import {AGENT_MASCOT_SHAPES,AGENT_MASCOT_BODIES,AGENT_MASCOT_COLORS,AGENT_MASCOT_EXPRESSIONS,AGENT_IMAGE_CROPS,agentIdentityFromProfile,agentIdentityMetadata,defaultAgentIdentity,decodeAgentAvatar,encodeAgentAvatar,normalizeAvatar,YAOYAO_AGENT_IDENTITY_NAMESPACE} from '../../src/shared/agentIdentity'
+import {AGENT_MASCOT_SHAPES,AGENT_MASCOT_BODIES,AGENT_MASCOT_COLORS,AGENT_MASCOT_EXPRESSIONS,AGENT_IMAGE_CROPS,agentIdentityFromProfile,agentIdentityMetadata,defaultAgentIdentity,randomAgentIdentity,decodeAgentAvatar,encodeAgentAvatar,normalizeAvatar,YAOYAO_AGENT_IDENTITY_NAMESPACE} from '../../src/shared/agentIdentity'
 const photo='data:image/png;base64,aGVsbG8='
 describe('avatar v2',()=>{
  it('resets legacy internal appearance while retaining the configured name',()=>{
@@ -29,6 +29,12 @@ describe('avatar v2',()=>{
  it('uses the same green circle for all defaults, regardless of name or role',()=>{
   for(const name of ['samien','developer','研究员']) expect(defaultAgentIdentity(name)).toMatchObject({shape:'circle',color:'#00c875',expression:'idle'})
  })
+ it('creates a complete random v2 identity without falling back to the fixed green circle',()=>{
+  const values=[0,0,.99];let index=0
+  expect(randomAgentIdentity('new-agent','新 Agent',()=>values[index++]!)).toMatchObject({shape:'circle',bodyId:null,color:'#000000',expression:'proud'})
+  index=0;values[0]=.99;values[1]=.99;values[2]=0
+  expect(randomAgentIdentity('new-agent','新 Agent',()=>values[index++]!)).toMatchObject({bodyId:'star',color:'#808080',expression:'idle'})
+ })
  it('rejects untrusted geometry, invalid crops and external image URLs',()=>{
   const base={...defaultAgentIdentity('bot')}
   for(const patch of [{shape:'<script>'},{bodyId:'untrusted'},{color:'url(https://x)'},{imageCrop:'blob'},{avatarMode:'image',imageDataURL:'https://external.test/avatar.png'}]) {