فهرست منبع

统一 OpenMausBot 头像、迁移头像数据并发布 v0.3.15

LSamien 1 ماه پیش
والد
کامیت
e7674d6eee
35فایلهای تغییر یافته به همراه2314 افزوده شده و 363 حذف شده
  1. 1 1
      README.md
  2. 4 0
      THIRD_PARTY_NOTICES.md
  3. 12 0
      docs/releases/v0.3.15.md
  4. 41 0
      docs/verification/avatar-v2.md
  5. 202 0
      licenses/OpenMausBot/LICENSE
  6. 6 0
      licenses/OpenMausBot/NOTICE
  7. 2 2
      package-lock.json
  8. 1 1
      package.json
  9. 2 0
      playwright.avatar.config.ts
  10. 1 1
      playwright.config.ts
  11. 3 3
      release.json
  12. 34 0
      scripts/seed-avatar-fixture.mjs
  13. 31 0
      scripts/serve-avatar-reference.mjs
  14. 8 0
      src/client/api/profiles.ts
  15. 9 7
      src/client/components/app/AgentIdentityFixture.vue
  16. 36 15
      src/client/components/app/AgentIdentityPanel.vue
  17. 1 1
      src/client/components/app/ResourceSidebar.vue
  18. 3 2
      src/client/components/app/types.ts
  19. 53 93
      src/client/components/common/AgentAvatar.vue
  20. 7 28
      src/client/components/common/TeamAvatar.vue
  21. 1328 0
      src/client/components/common/maus/cursor-engine.ts
  22. 154 0
      src/client/components/common/maus/cursor-face-data.ts
  23. 48 0
      src/client/components/common/maus/mascot-bodies.ts
  24. 22 0
      src/client/components/common/maus/silhouettes.ts
  25. 4 2
      src/client/components/messages/MessageTimeline.vue
  26. 8 5
      src/client/components/workspace/viewModels.ts
  27. 25 4
      src/server/workspaceStore.ts
  28. 59 119
      src/shared/agentIdentity.ts
  29. 3 1
      src/shared/workspace.ts
  30. 51 4
      tests/client/agentAvatar.test.ts
  31. 8 9
      tests/client/resourceSidebar.test.ts
  32. 29 0
      tests/e2e/avatarReference.spec.ts
  33. 38 4
      tests/e2e/workspace-chat.spec.ts
  34. 44 5
      tests/server/workspace.test.ts
  35. 36 56
      tests/shared/agentIdentity.test.ts

+ 1 - 1
README.md

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

+ 4 - 0
THIRD_PARTY_NOTICES.md

@@ -0,0 +1,4 @@
+
+## OpenMausBot avatar renderer
+
+Avatar geometry, face data and animation adapted from OpenMausBot commit 3a84701 (Apache-2.0), including its Blob Studio export. Local adaptations integrate Vue/SwiftUI, Yaoyao identity storage, activity and visibility. See licenses/OpenMausBot/LICENSE and NOTICE.

+ 12 - 0
docs/releases/v0.3.15.md

@@ -0,0 +1,12 @@
+# v0.3.15
+
+Web 与配套 iOS 1.3(176)的内置头像完整采用 OpenMausBot 的轮廓、纯色、五官、表情和状态动画。Logo 与聊天页面布局保持现状。
+
+- 设置提供 11 种颜色、8 种基础外形、10 种额外造型、10 种基础表情。选择基础外形会清除额外造型。
+- 上传照片支持圆形、圆角方形和方形裁剪,保存裁剪方式,不修改图片原始数据。
+- 旧内置头像一次性归一为绿色 `#00C875`、圆形、默认表情;旧照片保留,默认圆角。已保存的 v2 设置不会再次重置。基础 Agent 元数据通过现有 Profile 读取与保存接口归一化。
+- 群头像继续根据实际成员资料合成;执行、排队、待确认与完成/失败/新消息状态驱动对应头像动画。
+- 小尺寸空闲列表头像静止;活动头像、聊天头部与设置预览支持动态。Web 支持指针跟随。离屏、后台和减少动态效果时暂停。
+- 新增版本化头像描述,沿用原保存接口、图片存储、鉴权和大小限制;不需要 OpenMausBot 服务或插件升级。
+
+来源、许可与移植说明见 [THIRD_PARTY_NOTICES.md](../../THIRD_PARTY_NOTICES.md)。验证方法见 [头像验收记录](../verification/avatar-v2.md)。

+ 41 - 0
docs/verification/avatar-v2.md

@@ -0,0 +1,41 @@
+# 头像 v2 验收
+
+参考 OpenMausBot 提交 `3a84701`,参考仓库仅作读取。Web 移植为 Vue 与独立 SVG 动画控制器;iOS 移植对应 Swift 绘制代码与数据。没有新增运行服务或图片生成能力。
+
+## 外观
+
+`npx playwright test -c playwright.avatar.config.ts` 运行两个隔离页面:参考 React 实现与产品 Vue 实现。默认参考仓库为相邻 `OpenMausBot`,也可指定 `OPENMAUS_REFERENCE_ROOT`。
+
+28 个固定帧覆盖 8 个基础形状、10 个额外造型、10 个表情。只在临时参考副本中固定时钟为 0 并完成初始表情插值,逐项比较 SVG 轮廓属性、脸部锚点、眼睛路径与变换、嘴部路径与变换和纯色。
+
+iOS 的 `DeterministicFlowsUITests/testAvatarV2CanonicalFramesAndEditor` 截取相同 28 个固定帧,并实际选择额外造型、颜色,验证重新选择基础形状清除额外造型。
+
+本次对照通过。归一到相同尺寸后,Web 截图最大平均 RGB 差值约 0.012/255,iOS 与参考截图最大约 1.869/255;差异位于边缘抗锯齿及截图像素取整。背景透明,不附加头像底图。
+
+## 保存、迁移与运行
+
+- 共享编解码测试遍历形状、造型和照片裁剪;验证 v1 归一、v2 保留、隐藏照片不丢失和非法描述拒绝。
+- 服务端测试重启 SQLite 存储,验证一次性迁移保留角色规则、照片和单聊快照,后续 v2 修改不被重置。
+- Workspace 浏览器流程实际新建 Bot、聊天、组群、更新成员头像,并选择造型/表情/照片裁剪,保存重开后验证。
+- 运行态测试覆盖成员执行与待确认;追加验证排队任务不能覆盖同一成员的活动状态,过期结果不重播。
+- Web 渲染器测试验证空闲列表不安排动画帧、执行态播放、离屏/后台/减少动态效果暂停,以及照片加载失败降级。
+
+## iOS 与 Web 的真实存储回读
+
+使用隔离服务和合成账号,避免修改真实用户会话:
+
+```sh
+WORKSPACE_FIXTURE_PORT=18804 WORKSPACE_FIXTURE_UPSTREAM_PORT=19124 node --import tsx tests/fixtures/workspace-server.ts
+# 在另一个终端执行,每次测试前重置这两个测试头像:
+node scripts/seed-avatar-fixture.mjs
+```
+
+随后运行 iOS `DeterministicFlowsUITests/testAvatarV2ChangesRoundTripThroughWebStorage`。测试通过真实登录与 Bot 设置:Web 种下星星造型,iOS 改菱形;Web 种下圆形照片,iOS 改方形,保存重开后改圆角。每次用 Web API 回读,核对颜色、表情、造型/裁剪及原始图片字节未变。本次模拟器验证通过。
+
+签名 IPA 的打包与签名检查独立于模拟器验证;本次不把模拟器结果视作实体设备安装证明。
+
+## 回归范围与已知旧用例
+
+本次 Web 全量单元/服务测试 506 项通过,类型检查与生产构建通过;iOS 相关单元测试 62 项通过。当前 Bot 模式完整浏览器流程、28 组头像对照与两条 iOS UI 流程通过。
+
+通用旧浏览器套件仍包含已移除的团队/主题入口及插件升级状态文案;本次运行在这些旧断言上失败(7 条),停止后其余 30 条未执行,1 条中断。没有恢复旧入口以满足旧断言,也不将这次通用套件记为全量通过。当前 Bot 流程由 `playwright.workspace.config.ts` 的实际新建、设置、群聊与持久化流程验证;普通聊天与媒体另行回归。

+ 202 - 0
licenses/OpenMausBot/LICENSE

@@ -0,0 +1,202 @@
+
+                                 Apache License
+                           Version 2.0, January 2004
+                        http://www.apache.org/licenses/
+
+   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+   1. Definitions.
+
+      "License" shall mean the terms and conditions for use, reproduction,
+      and distribution as defined by Sections 1 through 9 of this document.
+
+      "Licensor" shall mean the copyright owner or entity authorized by
+      the copyright owner that is granting the License.
+
+      "Legal Entity" shall mean the union of the acting entity and all
+      other entities that control, are controlled by, or are under common
+      control with that entity. For the purposes of this definition,
+      "control" means (i) the power, direct or indirect, to cause the
+      direction or management of such entity, whether by contract or
+      otherwise, or (ii) ownership of fifty percent (50%) or more of the
+      outstanding shares, or (iii) beneficial ownership of such entity.
+
+      "You" (or "Your") shall mean an individual or Legal Entity
+      exercising permissions granted by this License.
+
+      "Source" form shall mean the preferred form for making modifications,
+      including but not limited to software source code, documentation
+      source, and configuration files.
+
+      "Object" form shall mean any form resulting from mechanical
+      transformation or translation of a Source form, including but
+      not limited to compiled object code, generated documentation,
+      and conversions to other media types.
+
+      "Work" shall mean the work of authorship, whether in Source or
+      Object form, made available under the License, as indicated by a
+      copyright notice that is included in or attached to the work
+      (an example is provided in the Appendix below).
+
+      "Derivative Works" shall mean any work, whether in Source or Object
+      form, that is based on (or derived from) the Work and for which the
+      editorial revisions, annotations, elaborations, or other modifications
+      represent, as a whole, an original work of authorship. For the purposes
+      of this License, Derivative Works shall not include works that remain
+      separable from, or merely link (or bind by name) to the interfaces of,
+      the Work and Derivative Works thereof.
+
+      "Contribution" shall mean any work of authorship, including
+      the original version of the Work and any modifications or additions
+      to that Work or Derivative Works thereof, that is intentionally
+      submitted to Licensor for inclusion in the Work by the copyright owner
+      or by an individual or Legal Entity authorized to submit on behalf of
+      the copyright owner. For the purposes of this definition, "submitted"
+      means any form of electronic, verbal, or written communication sent
+      to the Licensor or its representatives, including but not limited to
+      communication on electronic mailing lists, source code control systems,
+      and issue tracking systems that are managed by, or on behalf of, the
+      Licensor for the purpose of discussing and improving the Work, but
+      excluding communication that is conspicuously marked or otherwise
+      designated in writing by the copyright owner as "Not a Contribution."
+
+      "Contributor" shall mean Licensor and any individual or Legal Entity
+      on behalf of whom a Contribution has been received by Licensor and
+      subsequently incorporated within the Work.
+
+   2. Grant of Copyright License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      copyright license to reproduce, prepare Derivative Works of,
+      publicly display, publicly perform, sublicense, and distribute the
+      Work and such Derivative Works in Source or Object form.
+
+   3. Grant of Patent License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      (except as stated in this section) patent license to make, have made,
+      use, offer to sell, sell, import, and otherwise transfer the Work,
+      where such license applies only to those patent claims licensable
+      by such Contributor that are necessarily infringed by their
+      Contribution(s) alone or by combination of their Contribution(s)
+      with the Work to which such Contribution(s) was submitted. If You
+      institute patent litigation against any entity (including a
+      cross-claim or counterclaim in a lawsuit) alleging that the Work
+      or a Contribution incorporated within the Work constitutes direct
+      or contributory patent infringement, then any patent licenses
+      granted to You under this License for that Work shall terminate
+      as of the date such litigation is filed.
+
+   4. Redistribution. You may reproduce and distribute copies of the
+      Work or Derivative Works thereof in any medium, with or without
+      modifications, and in Source or Object form, provided that You
+      meet the following conditions:
+
+      (a) You must give any other recipients of the Work or
+          Derivative Works a copy of this License; and
+
+      (b) You must cause any modified files to carry prominent notices
+          stating that You changed the files; and
+
+      (c) You must retain, in the Source form of any Derivative Works
+          that You distribute, all copyright, patent, trademark, and
+          attribution notices from the Source form of the Work,
+          excluding those notices that do not pertain to any part of
+          the Derivative Works; and
+
+      (d) If the Work includes a "NOTICE" text file as part of its
+          distribution, then any Derivative Works that You distribute must
+          include a readable copy of the attribution notices contained
+          within such NOTICE file, excluding those notices that do not
+          pertain to any part of the Derivative Works, in at least one
+          of the following places: within a NOTICE text file distributed
+          as part of the Derivative Works; within the Source form or
+          documentation, if provided along with the Derivative Works; or,
+          within a display generated by the Derivative Works, if and
+          wherever such third-party notices normally appear. The contents
+          of the NOTICE file are for informational purposes only and
+          do not modify the License. You may add Your own attribution
+          notices within Derivative Works that You distribute, alongside
+          or as an addendum to the NOTICE text from the Work, provided
+          that such additional attribution notices cannot be construed
+          as modifying the License.
+
+      You may add Your own copyright statement to Your modifications and
+      may provide additional or different license terms and conditions
+      for use, reproduction, or distribution of Your modifications, or
+      for any such Derivative Works as a whole, provided Your use,
+      reproduction, and distribution of the Work otherwise complies with
+      the conditions stated in this License.
+
+   5. Submission of Contributions. Unless You explicitly state otherwise,
+      any Contribution intentionally submitted for inclusion in the Work
+      by You to the Licensor shall be under the terms and conditions of
+      this License, without any additional terms or conditions.
+      Notwithstanding the above, nothing herein shall supersede or modify
+      the terms of any separate license agreement you may have executed
+      with Licensor regarding such Contributions.
+
+   6. Trademarks. This License does not grant permission to use the trade
+      names, trademarks, service marks, or product names of the Licensor,
+      except as required for reasonable and customary use in describing the
+      origin of the Work and reproducing the content of the NOTICE file.
+
+   7. Disclaimer of Warranty. Unless required by applicable law or
+      agreed to in writing, Licensor provides the Work (and each
+      Contributor provides its Contributions) on an "AS IS" BASIS,
+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+      implied, including, without limitation, any warranties or conditions
+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+      PARTICULAR PURPOSE. You are solely responsible for determining the
+      appropriateness of using or redistributing the Work and assume any
+      risks associated with Your exercise of permissions under this License.
+
+   8. Limitation of Liability. In no event and under no legal theory,
+      whether in tort (including negligence), contract, or otherwise,
+      unless required by applicable law (such as deliberate and grossly
+      negligent acts) or agreed to in writing, shall any Contributor be
+      liable to You for damages, including any direct, indirect, special,
+      incidental, or consequential damages of any character arising as a
+      result of this License or out of the use or inability to use the
+      Work (including but not limited to damages for loss of goodwill,
+      work stoppage, computer failure or malfunction, or any and all
+      other commercial damages or losses), even if such Contributor
+      has been advised of the possibility of such damages.
+
+   9. Accepting Warranty or Additional Liability. While redistributing
+      the Work or Derivative Works thereof, You may choose to offer,
+      and charge a fee for, acceptance of support, warranty, indemnity,
+      or other liability obligations and/or rights consistent with this
+      License. However, in accepting such obligations, You may act only
+      on Your own behalf and on Your sole responsibility, not on behalf
+      of any other Contributor, and only if You agree to indemnify,
+      defend, and hold each Contributor harmless for any liability
+      incurred by, or claims asserted against, such Contributor by reason
+      of your accepting any such warranty or additional liability.
+
+   END OF TERMS AND CONDITIONS
+
+   APPENDIX: How to apply the Apache License to your work.
+
+      To apply the Apache License to your work, attach the following
+      boilerplate notice, with the fields enclosed by brackets "[]"
+      replaced with your own identifying information. (Don't include
+      the brackets!)  The text should be enclosed in the appropriate
+      comment syntax for the file format. We also recommend that a
+      file or class name and description of purpose be included on the
+      same "printed page" as the copyright notice for easier
+      identification within third-party archives.
+
+   Copyright [yyyy] [name of copyright owner]
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.

+ 6 - 0
licenses/OpenMausBot/NOTICE

@@ -0,0 +1,6 @@
+OpenMausBot
+Copyright 2026 Milind Soni and OpenMausBot contributors
+
+OpenMausBot was relicensed from the MIT License to the Apache License,
+Version 2.0, with the consent of all contributors whose copyright was
+incorporated before the transition.

+ 2 - 2
package-lock.json

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

+ 1 - 1
package.json

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

+ 2 - 0
playwright.avatar.config.ts

@@ -0,0 +1,2 @@
+import {defineConfig,devices} from '@playwright/test'
+export default defineConfig({testDir:'./tests/e2e',testMatch:'avatarReference.spec.ts',timeout:60000,workers:1,reporter:'list',use:{viewport:{width:1000,height:660},screenshot:'only-on-failure'},projects:[{name:'chromium',use:{...devices['Desktop Chrome']}}],webServer:{command:'node scripts/serve-avatar-reference.mjs',url:'http://127.0.0.1:18807',reuseExistingServer:false,timeout:30000}})

+ 1 - 1
playwright.config.ts

@@ -2,7 +2,7 @@ import { defineConfig, devices } from '@playwright/test'
 
 export default defineConfig({
   testDir: './tests/e2e',
-  testIgnore: '**/workspace-chat.spec.ts',
+  testIgnore: ['**/workspace-chat.spec.ts', '**/avatarReference.spec.ts'],
   timeout: 30_000,
   fullyParallel: false,
   workers: 1,

+ 3 - 3
release.json

@@ -1,6 +1,6 @@
 {
   "schemaVersion": 1,
-  "releaseVersion": "0.3.14",
-  "webVersion": "0.3.14",
-  "gitTag": "v0.3.14"
+  "releaseVersion": "0.3.15",
+  "webVersion": "0.3.15",
+  "gitTag": "v0.3.15"
 }

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

@@ -0,0 +1,34 @@
+// Prepare only the disposable workspace fixture for the iOS cross-client test.
+// Start tests/fixtures/workspace-server.ts on 18804 / 19124 before running this.
+import { readFileSync } from 'node:fs'
+const base = 'http://127.0.0.1:18804'
+let cookie = '', csrf = ''
+async function request(path, body, method) {
+  const response = await fetch(base + path, {
+    method: method ?? (body ? 'POST' : 'GET'),
+    headers: { Origin: base, Cookie: cookie, 'X-CSRF-Token': csrf, 'Content-Type': 'application/json' },
+    body: body ? JSON.stringify(body) : undefined,
+  })
+  const cookies = response.headers.getSetCookie()
+  if (cookies.length) cookie = cookies.map(value => value.split(';')[0]).join('; ')
+  if (!response.ok) throw new Error(`Fixture ${path}: ${response.status}`)
+  return response.json()
+}
+csrf = (await request('/api/app/bootstrap')).csrfToken
+await request('/api/app/login', { username: 'fixture', password: 'fixture-pass' })
+csrf = (await request('/api/app/bootstrap')).csrfToken
+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')
+for (const [name, image] of [['跨端造型', false], ['跨端照片', true]]) {
+  const avatar = 'yaoyao-avatar:v2:' + JSON.stringify({
+    version: 2, avatarMode: image ? 'image' : 'mascot', shape: 'circle', color: '#1488ff',
+    expression: 'proud', bodyId: image ? null : 'star', imageCrop: 'circle',
+    ...(image ? { imageDataURL: photo } : {}),
+  })
+  const existing = agents.find(agent => agent.name === name)
+  if (existing) await request(`/api/app/agents/${existing.id}`, { avatar }, 'PATCH')
+  else await request('/api/app/agents', { name, avatar, nodeId: source.nodeId, profile: source.profile })
+}
+console.log('Prepared two avatar fixtures on 127.0.0.1:18804')

+ 31 - 0
scripts/serve-avatar-reference.mjs

@@ -0,0 +1,31 @@
+import {createServer} from 'vite';
+import vue from '@vitejs/plugin-vue';
+import {mkdirSync,mkdtempSync,readFileSync,writeFileSync} from 'node:fs';
+import {resolve,join} from 'node:path';
+import {tmpdir} from 'node:os';
+import {fileURLToPath} from 'node:url';
+const target=resolve(fileURLToPath(new URL('..',import.meta.url))), source=process.env.OPENMAUS_REFERENCE_ROOT || resolve(target,'../OpenMausBot'), root=mkdtempSync(join(tmpdir(),'yaoyao-avatar-reference-'));
+mkdirSync(root+'/reference',{recursive:true});mkdirSync(root+'/ported',{recursive:true});
+const shapes=['circle','ellipse','square','capsule','triangle','hexagon','cloud','droplet'];
+const bodies=['cursor','blob','circle','squircle','capsule','drop','shield','hexagon','diamond','star'];
+const expressions=['idle','happy','curious','drowsy','working','thinking','listening','sleeping','suspicious','proud'];
+const cases=[...shapes.map(shape=>({id:'shape-'+shape,shape,color:'green',hex:'#00c875',expression:'idle'})),...bodies.map(bodyId=>({id:'body-'+bodyId,shape:'circle',bodyId,color:'blue',hex:'#1488ff',expression:'happy'})),...expressions.map(expression=>({id:'face-'+expression,shape:'circle',color:'pink',hex:'#f52ba5',expression}))];
+const html='<html><head><style>body{margin:0;background:white;font:12px Arial}.grid{display:grid;grid-template-columns:repeat(7,128px);gap:12px;padding:12px}.case{display:flex;flex-direction:column;align-items:center;gap:6px;width:128px;height:128px}.inline-flex{display:inline-flex}</style></head><body><div id="app"></div><script type="module" src="/main.tsx"></script></body></html>';
+for(const sub of ['reference','ported'])writeFileSync(root+'/'+sub+'/index.html',html);
+let cursor=readFileSync(source+'/src/components/CursorAvatar.tsx','utf8');
+cursor=cursor.replace('"../../shared/mascot-bodies"',JSON.stringify(source+'/shared/mascot-bodies.ts')).replace('"./cursor-face-data"',JSON.stringify(source+'/src/components/cursor-face-data.ts'));
+// Canonical resting-frame capture: settle the spring and hold elapsed time at 0.
+// This only controls the test clock; all original geometry/drawing code is unchanged.
+cursor=cursor.replace('if (p.paused) {','if (p.paused) { e.morph = 1; e.stateStart = now;');
+writeFileSync(root+'/reference/CursorAvatar.tsx',cursor);
+let avatar=readFileSync(source+'/src/components/Avatar.tsx','utf8').replace('"../../shared/bot-avatar"',JSON.stringify(source+'/shared/bot-avatar.ts')).replace('"../../shared/mascot-bodies"',JSON.stringify(source+'/shared/mascot-bodies.ts'));
+writeFileSync(root+'/reference/Avatar.tsx',avatar);
+writeFileSync(root+'/reference/main.tsx',`import React from 'react';import{createRoot}from'react-dom/client';import{MausAvatar}from'./Avatar';const cases=${JSON.stringify(cases)};createRoot(document.getElementById('app')!).render(<div className="grid">{cases.map(c=><div className="case" data-testid={c.id} key={c.id}><MausAvatar color={c.color as any} shape={(c.shape==='ellipse'?'oval':c.shape) as any} bodyId={c.bodyId as any} state={c.expression as any} size={96} animated={false}/><span>{c.id}</span></div>)}</div>);`);
+writeFileSync(root+'/ported/main.tsx',`import{createApp,h}from'vue';import AgentAvatar from '${target}/src/client/components/common/AgentAvatar.vue';import{defaultAgentIdentity,encodeAgentAvatar}from'${target}/src/shared/agentIdentity.ts';const cases=${JSON.stringify(cases)};createApp({render:()=>h('div',{class:'grid'},cases.map(c=>h('div',{class:'case','data-testid':c.id},[h(AgentAvatar,{name:c.id,avatar:encodeAgentAvatar({...defaultAgentIdentity(c.id),shape:c.shape,bodyId:c.bodyId??null,color:c.hex,expression:c.expression}),size:96,animated:false,fixedTime:0}),h('span',c.id)])))}).mount('#app');`);
+writeFileSync(root+'/cases.json',JSON.stringify(cases));
+const servers=[];
+for(const [sub,port]of[['reference',18806],['ported',18807]]){
+ const server=await createServer({configFile:false,root:root+'/'+sub,plugins:sub==='ported'?[vue()]:[],resolve:{alias:{'@':sub==='reference'?source+'/src':target+'/src/client','@shared':target+'/src/shared',react:source+'/node_modules/react','react-dom':source+'/node_modules/react-dom',vue:target+'/node_modules/vue'}},server:{host:'127.0.0.1',port,strictPort:true,fs:{allow:[root,source,target]}}});
+ await server.listen();servers.push(server);console.log(sub,port);
+}
+for(const signal of ['SIGINT','SIGTERM'])process.once(signal,async()=>{for(const server of servers)await server.close();process.exit()});

+ 8 - 0
src/client/api/profiles.ts

@@ -5,6 +5,7 @@ import { ChatRpcSocket } from './realtime'
 import {
   AGENT_MASCOT_EXPRESSIONS,
   AGENT_MASCOT_SHAPES,
+  AGENT_MASCOT_BODIES, AGENT_IMAGE_CROPS,
   YAOYAO_AGENT_IDENTITY_NAMESPACE,
   agentIdentityFromProfile,
   agentIdentityMetadata,
@@ -24,6 +25,8 @@ export type ProfileIdentityInput = {
   shape: AgentMascotShape
   color: string
   expression: AgentMascotExpression
+  bodyId?: import("@shared/agentIdentity").AgentMascotBody | null
+  imageCrop?: import("@shared/agentIdentity").AgentImageCrop
   avatarDataURL?: string | null
 }
 
@@ -51,6 +54,8 @@ function validateIdentity(input: ProfileIdentityInput): ProfileIdentityInput {
   }
   if (!AGENT_MASCOT_SHAPES.includes(input.shape)) throw new Error('请选择有效的头像形状')
   if (!AGENT_MASCOT_EXPRESSIONS.includes(input.expression)) throw new Error('请选择有效的基础表情')
+  if (input.bodyId != null && !AGENT_MASCOT_BODIES.includes(input.bodyId)) throw new Error('请选择有效的造型')
+  if (input.imageCrop != null && !AGENT_IMAGE_CROPS.includes(input.imageCrop)) throw new Error('请选择有效的裁剪')
   if (!/^#[0-9a-f]{6}$/i.test(input.color)) throw new Error('请选择有效的头像颜色')
   return {
     title,
@@ -58,6 +63,7 @@ function validateIdentity(input: ProfileIdentityInput): ProfileIdentityInput {
     shape: input.shape,
     color: input.color.toLowerCase(),
     expression: input.expression,
+    bodyId: input.bodyId ?? null, imageCrop: input.imageCrop ?? 'rounded',
     avatarDataURL,
   }
 }
@@ -86,6 +92,8 @@ export async function updateProfileIdentity(profile: Profile, input: ProfileIden
       shape: identity.shape,
       color: identity.color,
       expression: identity.expression,
+      bodyId: identity.bodyId ?? null,
+      imageCrop: identity.imageCrop ?? 'rounded',
       ...(identity.avatarDataURL ? { imageDataURL: identity.avatarDataURL } : {}),
     }
     if (!identity.avatarDataURL) delete nextIdentity.imageDataURL

+ 9 - 7
src/client/components/app/AgentIdentityFixture.vue

@@ -1,17 +1,19 @@
 <script setup lang="ts">
 import AgentAvatar from '@/components/common/AgentAvatar.vue'
 import TeamAvatar from '@/components/common/TeamAvatar.vue'
+import { defaultAgentIdentity, encodeAgentAvatar, type AgentMascotShape } from '@shared/agentIdentity'
+const avatar = (shape: AgentMascotShape) => encodeAgentAvatar({...defaultAgentIdentity('preview'), shape})
 
 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' },
+  { name: '圆圆', avatar: avatar('circle') },
+  { name: '方方', avatar: avatar('square') },
+  { name: '小三角', avatar: avatar('triangle') },
 ] 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' },
+  { name: '工作中', detail: '状态驱动的眼睛和身体动作', avatar: avatar('triangle'), state: 'working' },
+  { name: '有新消息', detail: '头像弹跳并提醒', avatar: avatar('triangle'), state: 'notifying' },
+  { name: '等待你操作', detail: '状态驱动的眼睛和身体动作', avatar: avatar('square'), state: 'waiting' },
 ] as const
 
 const teamMembers = shapes.map(item => ({ name: item.name, avatar: item.avatar }))
@@ -22,7 +24,7 @@ const teamMembers = shapes.map(item => ({ name: item.name, avatar: item.avatar }
     <header>
       <span>跨端角色系统</span>
       <h1>自定义 Agent 角色</h1>
-      <p>三种形状保持统一视觉面积;短三角不再使用细长指针轮廓。</p>
+      <p>采用 OpenMausBot 的纯色轮廓、五官与状态动画。</p>
     </header>
     <section class="shape-row" aria-label="三种头像形状">
       <article v-for="item in shapes" :key="item.name">

+ 36 - 15
src/client/components/app/AgentIdentityPanel.vue

@@ -6,12 +6,14 @@ import AppIcon from '@/components/common/AppIcon.vue'
 import { listModelCatalog } from '@/api/agentManagement'
 import type { ProfileIdentityInput } from '@/api/profiles'
 import {
-  AGENT_MASCOT_COLORS,
+  AGENT_MASCOT_COLORS, AGENT_MASCOT_COLOR_LABELS,
   AGENT_MASCOT_EXPRESSIONS,
   AGENT_MASCOT_SHAPES,
   AGENT_MASCOT_SHAPE_OPTIONS,
   agentIdentityFromProfile,
-  decodeAgentMascotAvatar,
+  decodeAgentAvatar,
+  AGENT_MASCOT_BODIES, AGENT_MASCOT_BODY_LABELS, AGENT_MASCOT_EXPRESSION_LABELS, AGENT_IMAGE_CROPS,
+  type AgentMascotBody, type AgentImageCrop,
   defaultAgentIdentity,
   encodeAgentAvatar,
   type AgentAvatarMode,
@@ -51,8 +53,10 @@ const title = ref('')
 const avatarDataURL = ref<string | null>(null)
 const avatarMode = ref<AgentAvatarMode>('mascot')
 const shape = ref<AgentMascotShape>('circle')
-const color = ref('#009957')
-const expression = ref<AgentMascotExpression>('friendly')
+const color = ref('#00c875')
+const bodyId = ref<AgentMascotBody | null>(null)
+const imageCrop = ref<AgentImageCrop>('rounded')
+const expression = ref<AgentMascotExpression>('idle')
 const localError = ref('')
 const fileInput = ref<HTMLInputElement>()
 const baseline = ref('')
@@ -67,6 +71,7 @@ function currentFingerprint(): string {
     shape: shape.value,
     color: color.value,
     expression: expression.value,
+  bodyId: bodyId.value, imageCrop: imageCrop.value,
     avatarDataURL: avatarDataURL.value,
   })
 }
@@ -75,11 +80,13 @@ function resetFromProfile() {
   profileGeneration += 1
   const profile = props.profile
   const nextTitle = profile.agentName || profile.displayName || profile.name || ''
-  const mascot = decodeAgentMascotAvatar(profile.agentAvatar)
+  const mascot = decodeAgentAvatar(profile.agentAvatar)
   const fallback = defaultAgentIdentity(profile.name, nextTitle)
   title.value = nextTitle
-  avatarDataURL.value = profile.agentAvatar?.startsWith('data:image/') ? profile.agentAvatar : null
-  avatarMode.value = avatarDataURL.value ? 'image' : 'mascot'
+  avatarDataURL.value = mascot?.imageDataURL ?? null
+  bodyId.value = mascot?.bodyId ?? null
+  imageCrop.value = mascot?.imageCrop ?? 'rounded'
+  avatarMode.value = mascot?.avatarMode ?? (avatarDataURL.value ? 'image' : 'mascot')
   shape.value = mascot?.shape ?? fallback.shape
   color.value = mascot?.color ?? fallback.color
   expression.value = mascot?.expression ?? fallback.expression
@@ -110,6 +117,7 @@ const previewAvatar = computed(() => encodeAgentAvatar({
   shape: shape.value,
   color: color.value,
   expression: expression.value,
+  bodyId: bodyId.value, imageCrop: imageCrop.value,
   ...(avatarDataURL.value ? { imageDataURL: avatarDataURL.value } : {}),
 }))
 watch(previewAvatar, value => emit('avatarChange', value), { immediate: true })
@@ -118,14 +126,13 @@ const dirty = computed(() => identityFingerprint.value !== baseline.value)
 watch(dirty, value => emit('dirty-change', value), { immediate: true })
 
 const shapeLabel: Record<AgentMascotShape, string> = { circle: '圆形', square: '圆角方形', triangle: '小三角', ellipse: '椭圆', capsule: '胶囊', hexagon: '六边形', cloud: '云朵', droplet: '水滴' }
-const expressionLabel: Record<AgentMascotExpression, string> = {
-  friendly: '友好', focused: '专注', curious: '好奇', calm: '沉稳',
-}
+const expressionLabel = AGENT_MASCOT_EXPRESSION_LABELS
 
 function mascotPreview(overrides: Partial<{
   shape: AgentMascotShape
   color: string
   expression: AgentMascotExpression
+  bodyId: AgentMascotBody | null
 }> = {}): string {
   return encodeAgentAvatar({
     ...agentIdentityFromProfile({ name: props.profile.name, display_name: title.value }),
@@ -134,6 +141,7 @@ function mascotPreview(overrides: Partial<{
     shape: overrides.shape ?? shape.value,
     color: overrides.color ?? color.value,
     expression: overrides.expression ?? expression.value,
+    bodyId: overrides.bodyId === undefined ? bodyId.value : overrides.bodyId,
   })
 }
 
@@ -186,6 +194,7 @@ async function chooseAvatar(event: Event) {
 
 function randomizeAvatar() {
   avatarMode.value = 'mascot'
+  bodyId.value = null
   shape.value = AGENT_MASCOT_SHAPES[Math.floor(Math.random() * AGENT_MASCOT_SHAPES.length)]!
   color.value = AGENT_MASCOT_COLORS[Math.floor(Math.random() * AGENT_MASCOT_COLORS.length)]!
   expression.value = AGENT_MASCOT_EXPRESSIONS[Math.floor(Math.random() * AGENT_MASCOT_EXPRESSIONS.length)]!
@@ -193,6 +202,8 @@ function randomizeAvatar() {
 function resetAvatar() {
   const original = defaultAgentIdentity(props.profile.name, title.value)
   avatarMode.value = 'mascot'
+  bodyId.value = null
+  imageCrop.value = 'rounded'
   shape.value = original.shape
   color.value = original.color
   expression.value = original.expression
@@ -208,6 +219,7 @@ function submit() {
     shape: shape.value,
     color: color.value,
     expression: expression.value,
+  bodyId: bodyId.value, imageCrop: imageCrop.value,
     avatarDataURL: avatarDataURL.value,
   })
   title.value = normalized
@@ -217,10 +229,10 @@ function submit() {
 <template>
   <component :is="embedded ? 'div' : 'form'" :id="formId" class="identity-form" @submit.prevent="submit">
     <div class="identity-avatar">
-      <AgentAvatar :name="title || profile.name" :avatar="previewAvatar" :size="112" state="idle" />
+      <AgentAvatar :name="title || profile.name" :avatar="previewAvatar" :size="112" state="idle" :animated="true" />
       <div>
         <strong>自定义角色</strong>
-        <small>由夭夭独立保存并同步到 Web 与 iOS,不再读取 Bots mode 的头像。</small>
+        <small>头像设置保存后同步到 Web 与 iOS。</small>
         <p>
           <button class="quiet-button" :class="{ active: avatarMode === 'mascot' }" type="button" :disabled="busy" @click="avatarMode = 'mascot'">动态吉祥物</button>
           <button class="quiet-button" :class="{ active: avatarMode === 'image' }" type="button" :disabled="busy || !avatarDataURL" @click="avatarMode = 'image'">上传图片</button>
@@ -234,12 +246,20 @@ function submit() {
       <div>
         <strong>形状</strong>
         <div class="mascot-grid mascot-grid--shape">
-          <button v-for="candidate in AGENT_MASCOT_SHAPE_OPTIONS" :key="candidate" type="button" :class="{ selected: shape === candidate }" :aria-pressed="shape === candidate" @click="shape = candidate">
-            <AgentAvatar :name="title || profile.name" :avatar="mascotPreview({ shape: candidate })" :size="44" />
+          <button v-for="candidate in AGENT_MASCOT_SHAPE_OPTIONS" :key="candidate" type="button" :class="{ selected: !bodyId && shape === candidate }" :aria-pressed="!bodyId && shape === candidate" @click="shape = candidate; bodyId = null">
+            <AgentAvatar :name="title || profile.name" :avatar="mascotPreview({ shape: candidate, bodyId: null })" :size="44" />
             <span>{{ shapeLabel[candidate] }}</span>
           </button>
         </div>
       </div>
+      <div>
+        <strong>造型</strong>
+        <div class="mascot-grid mascot-grid--shape">
+          <button v-for="candidate in AGENT_MASCOT_BODIES" :key="candidate" type="button" :class="{selected: bodyId === candidate}" :aria-pressed="bodyId === candidate" :aria-label="`造型:${AGENT_MASCOT_BODY_LABELS[candidate]}`" @click="bodyId = candidate">
+            <AgentAvatar :name="AGENT_MASCOT_BODY_LABELS[candidate]" :avatar="mascotPreview({bodyId:candidate})" :size="44" :animated="false" /><span>{{ AGENT_MASCOT_BODY_LABELS[candidate] }}</span>
+          </button>
+        </div>
+      </div>
       <div>
         <strong>基础表情</strong>
         <div class="mascot-grid mascot-grid--expression">
@@ -252,10 +272,11 @@ function submit() {
       <div>
         <strong>颜色</strong>
         <div class="color-grid">
-          <button v-for="candidate in AGENT_MASCOT_COLORS" :key="candidate" type="button" :class="{ selected: color === candidate }" :style="{ backgroundColor: candidate }" :aria-label="`使用颜色 ${candidate}`" :aria-pressed="color === candidate" @click="color = candidate" />
+          <button v-for="candidate in AGENT_MASCOT_COLORS" :key="candidate" type="button" :class="{ selected: color === candidate }" :style="{ backgroundColor: candidate }" :aria-label="`使用颜色 ${candidate}`" :title="AGENT_MASCOT_COLOR_LABELS[candidate]" :aria-pressed="color === candidate" @click="color = candidate" />
         </div>
       </div>
     </section>
+    <section v-if="avatarMode === 'image'" class="mascot-controls"><strong>图片裁剪</strong><div class="mascot-grid mascot-grid--shape"><button v-for="crop in AGENT_IMAGE_CROPS" :key="crop" type="button" :aria-pressed="imageCrop === crop" :class="{selected:imageCrop === crop}" @click="imageCrop = crop">{{ {circle:'圆形',rounded:'圆角方形',square:'方形'}[crop] }}</button></div></section>
     <div class="avatar-actions">
       <button class="quiet-button" type="button" :disabled="busy" @click="randomizeAvatar">随机外形</button>
       <button class="quiet-button" type="button" :disabled="busy" @click="resetAvatar">重置头像</button>

+ 1 - 1
src/client/components/app/ResourceSidebar.vue

@@ -152,7 +152,7 @@ defineExpose({ focusSearch })
               <AppIcon name="chevron-down" :size="13" />
             </button>
             <span v-if="!singleLine" class="sidebar-item__icon" :class="{ 'sidebar-item__icon--avatar': !row.item.icon }">
-              <AgentAvatar v-if="row.item.avatarKind === 'agent'" :name="row.item.title" :avatar="row.item.avatar" :state="row.item.avatarState" :size="avatarSize" />
+              <AgentAvatar v-if="row.item.avatarKind === 'agent'" :name="row.item.title" :avatar="row.item.avatar" :state="row.item.avatarState" :activity-key="row.item.avatarActivityKey" :size="avatarSize" />
               <TeamAvatar v-else-if="row.item.avatar !== undefined || row.item.avatarMembers?.length" :name="row.item.title" :avatar="row.item.avatar || ''" :members="row.item.avatarMembers || []" :fallback-key="row.item.avatarFallbackKey || row.item.id" :size="avatarSize" />
               <AppIcon v-else-if="row.item.icon" :name="row.item.icon" :size="row.item.topic ? 14 : 15" />
               <template v-else>{{ row.item.title.slice(0, 1).toUpperCase() }}</template>

+ 3 - 2
src/client/components/app/types.ts

@@ -15,10 +15,11 @@ export type SidebarItemBase = {
   status?: 'online' | 'working' | 'offline'
   icon?: 'chat' | 'groups' | 'file' | 'image' | 'video' | 'audio' | 'link' | 'artifacts' | 'branch' | 'topic' | 'chevron-left' | 'plus' | 'archive'
   avatarKind?: 'agent' | 'team'
-  avatarState?: 'idle' | 'working' | 'waiting'
+  avatarActivityKey?: number
+  avatarState?: 'idle' | 'working' | 'waiting' | 'loading' | 'success' | 'failure' | 'notifying'
   avatar?: string
   avatarFallbackKey?: string
-  avatarMembers?: Array<{ name: string; avatar?: string; state?: 'idle' | 'working' | 'waiting' }>
+  avatarMembers?: Array<{ activityKey?: number; name: string; avatar?: string; state?: 'idle' | 'working' | 'waiting' | 'loading' | 'success' | 'failure' | 'notifying' }>
   emptyText?: string
 }
 

+ 53 - 93
src/client/components/common/AgentAvatar.vue

@@ -1,102 +1,62 @@
 <script setup lang="ts">
-// 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
-  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('')}`
+import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
+import { decodeAgentAvatar, defaultAgentIdentity } from '@shared/agentIdentity'
+import { mountCursorAvatar, type CursorState } from './maus/cursor-engine'
+import { MASCOT_BODIES } from './maus/mascot-bodies'
+import { MASCOT_SILHOUETTES } from './maus/silhouettes'
+type MascotState = 'idle' | 'working' | 'loading' | 'notifying' | 'waiting' | 'success' | 'failure'
+const props = withDefaults(defineProps<{ name: string; avatar?: string; size?: number; state?: MascotState; animated?: boolean; fixedTime?: number; activityKey?: string | number }>(), { avatar: '', size: 32, state: 'idle', animated: undefined })
+const host = ref<HTMLElement>(), svg = ref<SVGSVGElement>(), visible = ref(true), foreground = ref(true), reduced = ref(false), hover = ref(false)
+const gaze = ref({x:0,y:0})
+const oneShot = ref<CursorState>()
+const beatEnded = ref(false), imageFailed = ref(false)
+let beat: ReturnType<typeof setTimeout> | undefined
+let observer: IntersectionObserver | undefined
+let media: MediaQueryList | undefined
+let renderer: ReturnType<typeof mountCursorAvatar> | undefined
+const identity = computed(() => decodeAgentAvatar(props.avatar) ?? defaultAgentIdentity(props.name))
+const hasImage = computed(() => identity.value.avatarMode === 'image' && !!identity.value.imageDataURL && !imageFailed.value)
+const radius = computed(() => identity.value.imageCrop === 'circle' ? '50%' : identity.value.imageCrop === 'square' ? '0' : '22%')
+const states: Record<MascotState, CursorState> = {idle:'idle',working:'working',loading:'loading',waiting:'alerting',notifying:'notifying',success:'happy',failure:'sad'}
+const runtimeActive = computed(() => props.state !== 'idle' && !beatEnded.value)
+const state = computed<CursorState>(() => runtimeActive.value ? states[props.state] : oneShot.value ?? identity.value.expression)
+const live = computed(() => visible.value && foreground.value && !reduced.value && props.animated !== false && (props.animated === true || props.size >= 56 || runtimeActive.value || !!oneShot.value || hover.value))
+const options = computed(() => ({ state: state.value, paused: !live.value, color: identity.value.color, gaze: gaze.value,
+  silhouette: identity.value.bodyId ? MASCOT_BODIES[identity.value.bodyId] : MASCOT_SILHOUETTES[identity.value.shape === 'ellipse' ? 'oval' : identity.value.shape], fixedTime: props.fixedTime }))
+function sync() {
+  if (!svg.value || hasImage.value) { renderer?.dispose();renderer=undefined;return }
+  if (!renderer) renderer=mountCursorAvatar(svg.value, options.value)
+  else renderer.update(options.value)
 }
-
-const highlight = computed(() => mix(mascot.value.color, '#ffffff', 0.54))
-const shadow = computed(() => mix(mascot.value.color, '#000000', 0.4))
-const faceTransform = computed(() => {
-  const shape = mascot.value.shape
-  if (shape === 'ellipse') return 'translate(0 10) translate(50 50) scale(1 .8) translate(-50 -50)'
-  if (shape === 'capsule') return 'translate(0 6) translate(50 50) scale(1 .7) translate(-50 -50)'
-  if (shape === 'triangle') return 'translate(0 9) translate(50 50) scale(.85 .85) translate(-50 -50)'
-  if (shape === 'droplet') return 'translate(0 8)'
-  return ''
-})
-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'
-  }
+function pointer(event: PointerEvent) {
+  if (reduced.value || props.animated === false) return
+  const rect = (event.currentTarget as HTMLElement).getBoundingClientRect()
+  hover.value=true
+  gaze.value={x:Math.max(-1,Math.min(1,(event.clientX-rect.left)/rect.width*2-1)),y:Math.max(-1,Math.min(1,(event.clientY-rect.top)/rect.height*2-1))}
+}
+function leave() { hover.value=false;gaze.value={x:0,y:0} }
+function visibility() { foreground.value=document.visibilityState!=='hidden' }
+function motion() { reduced.value=media?.matches ?? false }
+watch(() => props.avatar, () => { imageFailed.value=false })
+watch(() => [props.state, props.activityKey] as const, ([next]) => {
+  clearTimeout(beat);beatEnded.value=false;oneShot.value=undefined
+  if(props.fixedTime === undefined && ['success','failure','notifying'].includes(next)) beat=setTimeout(()=>{beatEnded.value=true},1400)
+},{immediate:true})
+watch([options,svg,hasImage],sync,{flush:'post'})
+onMounted(()=>{
+  visibility();media=window.matchMedia?.('(prefers-reduced-motion: reduce)');motion();media?.addEventListener('change',motion)
+  document.addEventListener('visibilitychange',visibility)
+  if(typeof IntersectionObserver!=='undefined' && host.value){observer=new IntersectionObserver(entries=>{visible.value=entries[0]?.isIntersecting ?? false});observer.observe(host.value)}
+  sync()
 })
+onBeforeUnmount(()=>{renderer?.dispose();observer?.disconnect();clearTimeout(beat);media?.removeEventListener('change',motion);document.removeEventListener('visibilitychange',visibility)})
 </script>
-
 <template>
-  <span
-    class="agent-avatar"
-    :class="[`agent-avatar--${state}`, { 'agent-avatar--image': hasImage }]"
-    :style="{ width: `${size}px`, height: `${size}px` }"
-    role="img"
-    :aria-label="`${name} 的头像`"
-  >
-    <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})`" />
-        <ellipse v-else-if="mascot.shape === 'ellipse'" cx="50" cy="50" rx="45" ry="33" :fill="`url(#${gradientId})`" />
-        <rect v-else-if="mascot.shape === 'capsule'" x="4" y="24" width="92" height="52" rx="26" :fill="`url(#${gradientId})`" />
-        <path v-else-if="mascot.shape === 'triangle'" d="M50 9 Q45 8 40 17 L10 76 Q4 88 19 88 L81 88 Q96 88 90 76 L60 17 Q55 8 50 9Z" :fill="`url(#${gradientId})`" />
-        <path v-else-if="mascot.shape === 'hexagon'" d="M46 6 Q50 4 54 6 L88 25 Q92 27 92 32 L92 68 Q92 73 88 75 L54 94 Q50 96 46 94 L12 75 Q8 73 8 68 L8 32 Q8 27 12 25Z" :fill="`url(#${gradientId})`" />
-        <path v-else-if="mascot.shape === 'cloud'" d="M25 22 C21 8 40 2 48 14 C56 1 78 9 76 23 C93 21 99 40 85 50 C101 65 89 85 73 80 C71 98 47 102 42 85 C27 99 9 84 16 69 C0 62 1 42 19 37 C13 31 16 24 25 22Z" :fill="`url(#${gradientId})`" />
-        <path v-else d="M50 5 C42 17 12 39 12 59 C12 80 29 95 50 95 C71 95 88 80 88 59 C88 39 58 17 50 5Z" :fill="`url(#${gradientId})`" />
-        <g class="agent-avatar__face" :transform="faceTransform">
-          <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 ref="host" class="agent-avatar" :class="[`agent-avatar--${props.state}`, {'agent-avatar--image':hasImage}]" :style="{width:`${size}px`,height:`${size}px`}" role="img" :aria-label="`${name} 的头像`" @pointermove="pointer" @pointerleave="leave" :data-animated="live && !hasImage" :data-expression="state">
+    <img @error="imageFailed = true" v-if="hasImage" :src="identity.imageDataURL" alt="" :style="{borderRadius:radius}" />
+    <svg v-else ref="svg" aria-hidden="true" />
   </span>
 </template>
-
 <style scoped>
-.agent-avatar{display:block;position:relative;flex:0 0 auto;overflow:visible;line-height:1;background:transparent}.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,.agent-avatar--working img{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,.agent-avatar--waiting img{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}}
+.agent-avatar{display:block;position:relative;flex:0 0 auto;overflow:visible;line-height:1;background:transparent}.agent-avatar img,.agent-avatar svg{display:block;width:100%;height:100%;object-fit:cover}
 </style>

+ 7 - 28
src/client/components/common/TeamAvatar.vue

@@ -1,12 +1,13 @@
 <script setup lang="ts">
 import { computed } from 'vue'
+import AppIcon from './AppIcon.vue'
 import AgentAvatar from './AgentAvatar.vue'
-import { AGENT_MASCOT_COLORS, AGENT_MASCOT_SHAPES } from '@shared/agentIdentity'
 
 export type TeamAvatarMember = {
   name: string
+  activityKey?: number
   avatar?: string
-  state?: 'idle' | 'working' | 'waiting'
+  state?: 'idle' | 'working' | 'waiting' | 'loading' | 'success' | 'failure' | 'notifying'
 }
 
 const props = withDefaults(defineProps<{
@@ -24,35 +25,12 @@ const props = withDefaults(defineProps<{
 
 const hasUploadedImage = computed(() => /^data:image\/(png|jpeg|webp);base64,/i.test(props.avatar))
 
-function stableIndex(value: string, count: number): number {
-  let hash = 2166136261
-  for (const byte of new TextEncoder().encode(value || 'team')) {
-    hash ^= byte
-    hash = Math.imul(hash, 16777619) >>> 0
-  }
-  return hash % count
-}
-
 const clusterMembers = computed(() => {
   const entries = props.members.slice(0, 3).map(member => ({
     name: member.name,
     avatar: member.avatar || '',
-    state: member.state || 'idle',
+    state: member.state || 'idle', activityKey: member.activityKey,
   }))
-  const key = props.fallbackKey || props.name || 'team'
-  const fallbackCount = entries.length ? entries.length : 3
-  while (entries.length < fallbackCount) {
-    const index = entries.length
-    const shape = AGENT_MASCOT_SHAPES[index]!
-    const color = AGENT_MASCOT_COLORS[
-      stableIndex(`${key}:group:${index}`, 10)
-    ]!.slice(1)
-    entries.push({
-      name: `${props.name || '团队'} ${index + 1}`,
-      avatar: `yaoyao-mascot:v1:${shape}:${color}:friendly`,
-      state: 'idle',
-    })
-  }
   return entries
 })
 </script>
@@ -65,6 +43,7 @@ const clusterMembers = computed(() => {
     :aria-label="`${name}团队头像`"
   >
     <img v-if="hasUploadedImage" class="team-avatar__image" :src="avatar" alt="" />
+    <AppIcon v-else-if="!clusterMembers.length" name="groups" :size="size" />
     <span v-else class="team-avatar__cluster" aria-hidden="true">
       <AgentAvatar
         v-for="(member, index) in clusterMembers"
@@ -73,7 +52,7 @@ const clusterMembers = computed(() => {
         :class="`team-avatar__member--${index + 1}`"
         :name="member.name"
         :avatar="member.avatar"
-        :state="member.state"
+        :state="member.state" :activity-key="member.activityKey"
         :size="Math.round(size * .64)"
       />
     </span>
@@ -84,7 +63,7 @@ const clusterMembers = computed(() => {
 .team-avatar { position: relative; display: block; flex: 0 0 auto; overflow: visible; }
 .team-avatar__image { display: block; width: 100%; height: 100%; object-fit: cover; border-radius: 50%; box-shadow: inset 0 0 0 1px var(--line); }
 .team-avatar__cluster { position: absolute; inset: 0; display: block; }
-.team-avatar__member { position: absolute; filter: drop-shadow(0 1px 1px color-mix(in srgb, #000 18%, transparent)); }
+.team-avatar__member { position: absolute; }
 .team-avatar__member--1 { z-index: 3; left: 0; top: 18%; }
 .team-avatar__member--2 { z-index: 2; top: 0; right: 0; }
 .team-avatar__member--3 { z-index: 1; right: 1%; bottom: 0; }

+ 1328 - 0
src/client/components/common/maus/cursor-engine.ts

@@ -0,0 +1,1328 @@
+// Adapted from OpenMausBot (3a84701), Apache-2.0. See THIRD_PARTY_NOTICES.md.
+/**
+ * CursorAvatar — an animated mascot built on the "cursor" silhouette.
+ *
+ * Data and frame functions retained from OpenMausBot; the host below is framework-neutral.
+ *
+ *   import CursorAvatar from './CursorAvatar'
+ *
+ *   <CursorAvatar state="thinking" silhouette={MASCOT_BODIES.cursor} size={160} />
+ *
+ * The state drives everything — which expressions cycle, how often, and when it blinks.
+ * Set `state` and it animates itself; see CURSOR_STATES for the full list.
+ *
+ * Props of note:
+ *   state        one of CURSOR_STATES
+ *   expression   pin a single face and stop the cycling
+ *   lookAround   how much each expression glances around. 0 = always straight ahead
+ *   gaze / turn  aim the eyes, or rotate the head around its implied sphere
+ *   showMouth    false for an eyes-only face
+ *
+ * Made with Blob Studio.
+ */
+
+import { MASCOT_BODIES } from "./mascot-bodies"
+import {
+  EXPRESSIONS,
+  EXPRESSION_COUNT,
+  FACE_BOX,
+  FACE_CENTRE,
+  GAZE,
+  GAZE_TRAVEL,
+  MOUTHS,
+  MOUTH_STROKE,
+  mouthFrame,
+  type Ring,
+} from "./cursor-face-data"
+
+export {
+  EXPRESSIONS,
+  EXPRESSION_COUNT,
+  FACE_BOX,
+  FACE_CENTRE,
+  GAZE,
+  GAZE_TRAVEL,
+  MOUTHS,
+  MOUTH_STROKE,
+  mouthFrame,
+}
+export type { Ring }
+
+/* ------------------------------------------------------------------- shape */
+
+export interface CursorSilhouette {
+  /** Human-readable name, used for the accessible label. */
+  name: string
+  /** Transform mapping the artwork into the 228.541-unit face box. '' for none. */
+  fit: string
+  /** SVG markup for the body. The token {{GRADIENT}} is replaced with the instance gradient. */
+  body: string
+  /** SVG markup defining the clip region — the union of the silhouette's filled shapes. */
+  clip: string
+  /** Where the face sits inside the silhouette, in face-space units. */
+  anchor: { x: number; y: number; scale: number }
+}
+
+// The generator solves this body's face placement once; `MASCOT_BODIES.cursor`
+// is the single source of truth desktop and iOS both build from. Its shape
+// carries one extra field (`id`) that `CursorSilhouette` does not, so it is
+// derived here rather than assigned directly.
+const { id: _cursorBodyId, ...cursorSilhouette } = MASCOT_BODIES.cursor;
+export const DEFAULT_SILHOUETTE: CursorSilhouette = cursorSilhouette;
+
+export const DEFAULT_GRADIENT: [string, string, string] = ["#9FE6B5","#3FAE6E","#1C7A4C"]
+
+const VIEW_BOX = `-15 -15 ${FACE_BOX + 30} ${FACE_BOX + 30}`
+const SPHERE_C = 114.2705
+const SPHERE_R = 105
+
+/* ------------------------------------------------------------------ motion */
+
+/**
+ * How the body itself moves. The face engine on its own leaves the silhouette perfectly
+ * still, which reads as dead for states literally named `bouncing` or `spawning`.
+ *
+ * All of this is shape-agnostic — it moves whatever silhouette it is given, so an uploaded
+ * logo animates exactly like the built-in circle.
+ *
+ *   bob     vertical travel, [amplitude in face units, period ms]
+ *   sway    rotation, [degrees, period ms]
+ *   pulse   uniform scale, [fraction, period ms] — breathing
+ *   circle  orbital drift, [radius, period ms]
+ *   jitter  fast nervous shake, [amplitude, period ms]
+ *   tilt    constant lean, degrees
+ *   squash  0..1, how much a bob squashes the body at the bottom of its arc
+ *   enter   one-shot on entering the state, [starting scale, duration ms]
+ *   settle  scale it eases to and holds, for exits like powering-down
+ */
+export interface BodyMotion {
+  bob?: [number, number]
+  sway?: [number, number]
+  pulse?: [number, number]
+  circle?: [number, number]
+  jitter?: [number, number]
+  tilt?: number
+  squash?: number
+  enter?: [number, number]
+  settle?: number
+}
+
+export const MOTION = {
+  // Lifecycle — quiet, breathing, alive but not busy.
+  sleeping: { pulse: [0.028, 4600], tilt: 2 },
+  waking: { enter: [0.92, 700], pulse: [0.03, 2200] },
+  idle: { pulse: [0.014, 3600] },
+  listening: { bob: [2, 2600], pulse: [0.012, 2600] },
+  thinking: { sway: [1.6, 3000], pulse: [0.01, 3000] },
+  searching: { bob: [3, 1400], sway: [2.2, 1400] },
+  working: { bob: [2.5, 900], squash: 0.22 },
+
+  // Reactions — the loud half.
+  excited: { bob: [9, 520], sway: [3, 1040], squash: 0.35 },
+  surprised: { enter: [1.14, 340], jitter: [0.8, 120] },
+  suspicious: { sway: [2.4, 2600], tilt: -3 },
+  angry: { jitter: [1.3, 95], tilt: 2 },
+  drowsy: { pulse: [0.026, 5000], tilt: 3 },
+  happy: { bob: [5, 820], squash: 0.28 },
+  curious: { sway: [3.4, 1900], tilt: -4 },
+  confused: { sway: [3, 2200] },
+  bored: { pulse: [0.016, 5200], tilt: 2 },
+  proud: { bob: [1.6, 2400], pulse: [0.02, 2400] },
+  shy: { pulse: [0.016, 3000], tilt: 4 },
+  sad: { pulse: [0.02, 4600], tilt: 3 },
+  laughing: { bob: [7, 430], squash: 0.4 },
+  scared: { jitter: [2.2, 75] },
+  playful: { bob: [6, 620], sway: [5, 1240], squash: 0.3 },
+  celebrate: { bob: [10, 480], sway: [4, 960], squash: 0.35 },
+
+  // Agent morphs — the mascot standing in for a process.
+  orbit: { circle: [6, 3200] },
+  radar: { sway: [6, 2400], pulse: [0.012, 2400] },
+  progress: { pulse: [0.022, 1600] },
+
+  // Product cycle.
+  spawning: { enter: [0.02, 820], pulse: [0.014, 3600] },
+  humming: { pulse: [0.016, 2800] },
+  loading: { sway: [2.2, 1500], pulse: [0.012, 1500] },
+  dictating: { bob: [2, 2000] },
+  writing: { bob: [1.6, 1100] },
+  sending: { bob: [3, 900] },
+  receiving: { bob: [3, 900] },
+  uploading: { bob: [3, 1000] },
+  notifying: { bob: [4, 700], sway: [2.5, 700] },
+  alerting: { jitter: [2.6, 85] },
+  dragging: { tilt: -6, sway: [2, 900] },
+  bouncing: { bob: [12, 560], squash: 0.45 },
+  'powering-down': { settle: 0.05, tilt: 4 },
+} satisfies Record<CursorState, BodyMotion>
+
+/** How long a `settle` takes to reach its resting scale. */
+const SETTLE_MS = 1400
+
+/* ----------------------------------------------------------------- effects */
+
+/**
+ * Confetti, motion ribbons, and glyph morphs.
+ *
+ * All three are shape-agnostic: they orbit, burst around, or stand in for whatever
+ * silhouette they are given, so an uploaded logo behaves exactly like the built-in circle.
+ *
+ * These layers are driven imperatively from the frame loop rather than through React
+ * state. A celebrate burst is ~14 elements changing every frame, and a page showing all
+ * states at once would otherwise re-render continuously.
+ */
+
+const TAU = Math.PI * 2
+
+/** Deterministic per-particle randomness — same seed, same particle, every run. */
+const hash01 = (n: number) => {
+  const x = Math.sin(n * 127.1 + 311.7) * 43758.5453
+  return x - Math.floor(x)
+}
+
+const CONFETTI_COLORS = ['#F472B6', '#C084FC', '#818CF8', '#38BDF8', '#34D399', '#FACC15', '#FB7185']
+const RIBBON_COLORS = ['#4ADE80', '#34D399', '#22D3EE', '#60A5FA']
+
+export interface ConfettiSpec {
+  /** How many pieces are in flight per burst. */
+  count: number
+  /** Milliseconds between bursts. */
+  period: number
+  /** How long one piece lives, ms. */
+  life: number
+  /** Radius the pieces launch from, so a burst clears the face instead of covering it. */
+  origin: number
+  /** How far pieces travel beyond that, in face units. */
+  spread: number
+}
+
+export interface TrailSpec {
+  /** How many ribbons orbit the body. */
+  count: number
+  /** Milliseconds for one full orbit. */
+  period: number
+  /** Orbit radius, in face units. */
+  radius: number
+}
+
+export interface GlyphSpec {
+  /** Markup drawn in place of the body. {{GRADIENT}} is replaced with the body paint. */
+  markup: string
+  /** Milliseconds between appearances. */
+  period: number
+  /** How long the glyph is held, ms. */
+  hold: number
+}
+
+export interface StateEffects {
+  confetti?: ConfettiSpec
+  trails?: TrailSpec
+  glyph?: GlyphSpec
+}
+
+interface EffectsByState extends Partial<Record<CursorState, StateEffects>> {}
+
+/**
+ * The exclamation mark the mascot becomes when something needs attention — drawn as a
+ * tapered bar and a dot so it reads as a character rather than a rectangle.
+ */
+const GLYPH_BANG =
+  '<path fill="{{GRADIENT}}" d="M99 58 Q99 43 114.3 43 Q129.6 43 129.6 58 L123 150 Q122 161 114.3 161 Q106.6 161 105.6 150 Z"/>' +
+  '<circle fill="{{GRADIENT}}" cx="114.3" cy="188" r="15"/>'
+
+/** The question mark for genuine confusion. Stroked, so it stays light against the body. */
+const GLYPH_QUERY =
+  '<path fill="none" stroke="{{GRADIENT}}" stroke-width="19" stroke-linecap="round" ' +
+  'd="M88 76 A27 27 0 1 1 114.3 112 L114.3 132"/>' +
+  '<circle fill="{{GRADIENT}}" cx="114.3" cy="170" r="13"/>'
+
+export const EFFECTS: EffectsByState = {
+  // Celebration — the loud burst.
+  // Travel is deliberately bounded: the viewBox only carries 15 units of margin, so a
+  // piece thrown much past ~130 from centre would be clipped mid-flight.
+  celebrate: { confetti: { count: 16, period: 1500, life: 1300, origin: 74, spread: 54 } },
+  excited: { confetti: { count: 9, period: 2000, life: 1100, origin: 72, spread: 44 } },
+  laughing: { confetti: { count: 7, period: 2400, life: 1000, origin: 70, spread: 38 } },
+
+  // Work in flight — ribbons circling the body.
+  orbit: { trails: { count: 3, period: 2600, radius: 128 } },
+  radar: { trails: { count: 2, period: 2000, radius: 132 } },
+  progress: { trails: { count: 3, period: 1800, radius: 124 } },
+  loading: { trails: { count: 2, period: 2200, radius: 126 } },
+  uploading: { trails: { count: 2, period: 1700, radius: 122 } },
+  sending: { trails: { count: 2, period: 1500, radius: 120 } },
+  receiving: { trails: { count: 2, period: 1500, radius: 120 } },
+
+  // The mascot standing aside to show a symbol.
+  alerting: { glyph: { markup: GLYPH_BANG, period: 2600, hold: 1100 } },
+  notifying: { glyph: { markup: GLYPH_BANG, period: 4200, hold: 900 } },
+  confused: { glyph: { markup: GLYPH_QUERY, period: 5200, hold: 1200 } },
+}
+
+const SVG_NS = 'http://www.w3.org/2000/svg'
+
+/** Grows or shrinks a layer's pool of <path> children to exactly `count`. */
+function poolPaths(layer: SVGGElement, count: number): SVGPathElement[] {
+  while (layer.childNodes.length > count) layer.removeChild(layer.lastChild!)
+  while (layer.childNodes.length < count) {
+    const path = document.createElementNS(SVG_NS, 'path')
+    path.setAttribute('stroke-linecap', 'round')
+    path.setAttribute('fill', 'none')
+    layer.appendChild(path)
+  }
+  return Array.from(layer.querySelectorAll<SVGPathElement>(':scope > path'))
+}
+
+/** Confetti: short curved strokes thrown outward, arcing down as they fade. */
+function drawConfetti(
+  layer: SVGGElement,
+  spec: ConfettiSpec,
+  elapsed: number,
+  strength: number,
+  cx: number,
+  cy: number
+) {
+  const paths = poolPaths(layer, spec.count)
+  for (let i = 0; i < spec.count; i++) {
+    const path = paths[i]
+    const seedA = hash01(i + 1)
+    const seedB = hash01(i + 41)
+    const seedC = hash01(i + 91)
+
+    // Stagger the pieces across the burst window so they don't leave as one wall.
+    const t = (((elapsed + seedC * spec.period) % spec.period) / spec.life) * 1
+    if (t > 1) {
+      path.setAttribute('opacity', '0')
+      continue
+    }
+
+    const angle = seedA * TAU
+    const travel = spec.spread * (0.45 + seedB * 0.55) * (1 - (1 - t) * (1 - t))
+    const distance = (spec.origin + travel) * strength
+    const gravity = 34 * strength * t * t
+    const x = cx + Math.cos(angle) * distance
+    const y = cy + Math.sin(angle) * distance + gravity
+    const length = 11 + seedB * 12
+    const spin = angle + (seedC - 0.5) * 5 * t
+    const ex = x + Math.cos(spin) * length
+    const ey = y + Math.sin(spin) * length
+    // A slight bend reads as paper rather than a matchstick.
+    const bend = (seedA - 0.5) * length * 0.7
+    const mx = (x + ex) / 2 - Math.sin(spin) * bend
+    const my = (y + ey) / 2 + Math.cos(spin) * bend
+
+    path.setAttribute('d', `M${x.toFixed(1)} ${y.toFixed(1)} Q${mx.toFixed(1)} ${my.toFixed(1)} ${ex.toFixed(1)} ${ey.toFixed(1)}`)
+    path.setAttribute('stroke', CONFETTI_COLORS[i % CONFETTI_COLORS.length])
+    path.setAttribute('stroke-width', (4 + seedB * 2.4).toFixed(1))
+    path.setAttribute('opacity', (t < 0.12 ? t / 0.12 : 1 - (t - 0.12) / 0.88).toFixed(3))
+  }
+}
+
+/** Ribbons: arcs sweeping around the body on their own periods. */
+function drawTrails(
+  layer: SVGGElement,
+  spec: TrailSpec,
+  elapsed: number,
+  strength: number,
+  cx: number,
+  cy: number
+) {
+  const paths = poolPaths(layer, spec.count)
+  const SAMPLES = 12
+  for (let i = 0; i < spec.count; i++) {
+    const path = paths[i]
+    const seedA = hash01(i + 3)
+    const seedB = hash01(i + 29)
+    const direction = i % 2 === 0 ? 1 : -1
+    const period = spec.period * (0.8 + seedA * 0.5)
+    const radius = spec.radius * strength * (0.78 + seedB * 0.4)
+    const start = direction * (elapsed / period) * TAU + seedA * TAU
+    const span = 0.85 + seedB * 0.7
+
+    let d = ''
+    for (let s = 0; s <= SAMPLES; s++) {
+      const k = s / SAMPLES
+      const angle = start + span * k
+      // Breathe the radius along the arc so the ribbon curls instead of tracing a circle.
+      const r = radius * (1 + Math.sin(k * Math.PI) * 0.14)
+      const x = cx + Math.cos(angle) * r
+      const y = cy + Math.sin(angle) * r * 0.78
+      d += (s === 0 ? 'M' : 'L') + x.toFixed(1) + ' ' + y.toFixed(1)
+    }
+    path.setAttribute('d', d)
+    path.setAttribute('stroke', RIBBON_COLORS[i % RIBBON_COLORS.length])
+    path.setAttribute('stroke-width', (5 + seedA * 3).toFixed(1))
+    path.setAttribute('opacity', '0.85')
+  }
+}
+
+/** How present the glyph is right now, 0..1, with eased edges. */
+function glyphAmount(spec: GlyphSpec, elapsed: number): number {
+  const position = elapsed % spec.period
+  const start = spec.period - spec.hold
+  if (position < start) return 0
+  const into = position - start
+  const remaining = spec.hold - into
+  const EASE = 200
+  return Math.max(0, Math.min(1, Math.min(into, remaining) / EASE))
+}
+
+export interface EffectFrame {
+  trails: SVGGElement | null
+  confetti: SVGGElement | null
+  glyph: SVGGElement | null
+  bodyContent: SVGGElement | null
+  state: CursorState
+  elapsed: number
+  strength: number
+  paint: string
+  showEffects: boolean
+  showGlyphs: boolean
+}
+
+/** Called once per frame from the engine loop. */
+export function updateEffects(frame: EffectFrame) {
+  const spec = EFFECTS[frame.state]
+  const centre = FACE_BOX / 2
+  const strength = frame.strength
+
+  if (frame.trails) {
+    if (spec?.trails && frame.showEffects && strength > 0) {
+      drawTrails(frame.trails, spec.trails, frame.elapsed, strength, centre, centre)
+    } else if (frame.trails.childNodes.length) {
+      frame.trails.replaceChildren()
+    }
+  }
+
+  if (frame.confetti) {
+    if (spec?.confetti && frame.showEffects && strength > 0) {
+      drawConfetti(frame.confetti, spec.confetti, frame.elapsed, strength, centre, centre)
+    } else if (frame.confetti.childNodes.length) {
+      frame.confetti.replaceChildren()
+    }
+  }
+
+  if (frame.glyph && frame.bodyContent) {
+    if (spec?.glyph && frame.showGlyphs) {
+      const amount = glyphAmount(spec.glyph, frame.elapsed)
+      const markup = spec.glyph.markup.replace(/\{\{GRADIENT\}\}/g, frame.paint)
+      if (frame.glyph.getAttribute('data-glyph') !== markup) {
+        frame.glyph.setAttribute('data-glyph', markup)
+        frame.glyph.innerHTML = markup
+      }
+      frame.glyph.style.opacity = String(amount)
+      // Scale up as it arrives, so the swap reads as a transformation.
+      const scale = 0.72 + 0.28 * amount
+      frame.glyph.setAttribute(
+        'transform',
+        `translate(${centre} ${centre}) scale(${scale.toFixed(3)}) translate(${-centre} ${-centre})`
+      )
+      // The body steps aside rather than sitting behind the glyph.
+      frame.bodyContent.style.opacity = String(1 - amount)
+    } else {
+      if (frame.glyph.getAttribute('data-glyph')) {
+        frame.glyph.removeAttribute('data-glyph')
+        frame.glyph.replaceChildren()
+      }
+      frame.glyph.style.opacity = '0'
+      frame.bodyContent.style.opacity = '1'
+    }
+  }
+}
+
+/* ------------------------------------------------------------------ states */
+
+export type CursorState =
+  | "sleeping"
+  | "waking"
+  | "idle"
+  | "listening"
+  | "thinking"
+  | "searching"
+  | "working"
+  | "excited"
+  | "surprised"
+  | "suspicious"
+  | "angry"
+  | "drowsy"
+  | "happy"
+  | "curious"
+  | "confused"
+  | "bored"
+  | "proud"
+  | "shy"
+  | "sad"
+  | "laughing"
+  | "scared"
+  | "playful"
+  | "celebrate"
+  | "orbit"
+  | "radar"
+  | "progress"
+  | "spawning"
+  | "humming"
+  | "loading"
+  | "dictating"
+  | "sending"
+  | "receiving"
+  | "uploading"
+  | "writing"
+  | "notifying"
+  | "alerting"
+  | "bouncing"
+  | "dragging"
+  | "powering-down"
+
+/**
+ * Which expressions a state cycles through. The first is its resting face, chosen as the
+ * pool's most forward-facing member so a mascot at rest looks at you rather than past you.
+ */
+export const POOLS = {
+  sleeping: [
+    22,
+    13,
+    4
+  ],
+  waking: [
+    13
+  ],
+  idle: [
+    6,
+    0,
+    8
+  ],
+  listening: [
+    1,
+    10,
+    19
+  ],
+  thinking: [
+    17,
+    8,
+    16,
+    14,
+    5
+  ],
+  searching: [
+    20,
+    15,
+    9,
+    3,
+    12,
+    18
+  ],
+  working: [
+    10,
+    7,
+    16,
+    11
+  ],
+  excited: [
+    2,
+    17,
+    21,
+    3,
+    11
+  ],
+  surprised: [
+    21,
+    3
+  ],
+  suspicious: [
+    5,
+    14,
+    23
+  ],
+  angry: [
+    7,
+    16
+  ],
+  drowsy: [
+    22,
+    4,
+    13
+  ],
+  happy: [
+    19,
+    2,
+    11,
+    17
+  ],
+  curious: [
+    21,
+    3,
+    0,
+    15
+  ],
+  confused: [
+    8,
+    14,
+    5
+  ],
+  bored: [
+    0,
+    4,
+    22
+  ],
+  proud: [
+    2,
+    15,
+    8
+  ],
+  shy: [
+    24,
+    0,
+    13
+  ],
+  sad: [
+    22,
+    4,
+    13
+  ],
+  laughing: [
+    2,
+    11,
+    17
+  ],
+  scared: [
+    21,
+    3
+  ],
+  playful: [
+    2,
+    17,
+    11,
+    8
+  ],
+  celebrate: [
+    2,
+    8,
+    17
+  ],
+  orbit: [
+    6,
+    0,
+    8
+  ],
+  radar: [
+    6,
+    0,
+    8
+  ],
+  progress: [
+    6,
+    0,
+    8
+  ],
+  spawning: [
+    3,
+    0
+  ],
+  humming: [
+    6,
+    0,
+    8
+  ],
+  loading: [
+    6,
+    0,
+    8
+  ],
+  dictating: [
+    1,
+    10,
+    19
+  ],
+  sending: [
+    6,
+    0,
+    8
+  ],
+  receiving: [
+    19,
+    0,
+    8
+  ],
+  uploading: [
+    15,
+    9,
+    8
+  ],
+  writing: [
+    15,
+    9
+  ],
+  notifying: [
+    21,
+    3,
+    0
+  ],
+  alerting: [
+    21,
+    3
+  ],
+  bouncing: [
+    2,
+    17
+  ],
+  dragging: [
+    3,
+    15,
+    0
+  ],
+  "powering-down": [
+    22,
+    13
+  ]
+} satisfies Record<CursorState, number[]>
+
+/** How long a state holds an expression before drifting to another, in ms. */
+const EXPR_CADENCE = {
+  sleeping: [
+    6000,
+    10000
+  ],
+  waking: [
+    800,
+    800
+  ],
+  idle: [
+    9000,
+    16000
+  ],
+  listening: [
+    2800,
+    5000
+  ],
+  thinking: [
+    2000,
+    3600
+  ],
+  searching: [
+    1000,
+    1800
+  ],
+  working: [
+    1800,
+    3200
+  ],
+  excited: [
+    1100,
+    2000
+  ],
+  surprised: [
+    2500,
+    4000
+  ],
+  suspicious: [
+    2600,
+    4500
+  ],
+  angry: [
+    2200,
+    3800
+  ],
+  drowsy: [
+    4000,
+    8000
+  ],
+  happy: [
+    2500,
+    4500
+  ],
+  curious: [
+    1800,
+    3200
+  ],
+  confused: [
+    2200,
+    3800
+  ],
+  bored: [
+    3500,
+    6000
+  ],
+  proud: [
+    3500,
+    6000
+  ],
+  shy: [
+    3000,
+    5500
+  ],
+  sad: [
+    4000,
+    7000
+  ],
+  laughing: [
+    1200,
+    2400
+  ],
+  scared: [
+    900,
+    1800
+  ],
+  playful: [
+    1500,
+    3000
+  ],
+  celebrate: [
+    1400,
+    2600
+  ],
+  orbit: [
+    4000,
+    8000
+  ],
+  radar: [
+    4000,
+    8000
+  ],
+  progress: [
+    4000,
+    8000
+  ],
+  spawning: [
+    1200,
+    1200
+  ],
+  humming: [
+    5000,
+    9000
+  ],
+  loading: [
+    6000,
+    10000
+  ],
+  dictating: [
+    4000,
+    8000
+  ],
+  sending: [
+    4000,
+    8000
+  ],
+  receiving: [
+    4000,
+    8000
+  ],
+  uploading: [
+    4000,
+    8000
+  ],
+  writing: [
+    4000,
+    8000
+  ],
+  notifying: [
+    1500,
+    2600
+  ],
+  alerting: [
+    2000,
+    3600
+  ],
+  bouncing: [
+    3000,
+    6000
+  ],
+  dragging: [
+    1600,
+    3000
+  ],
+  "powering-down": [
+    6000,
+    9000
+  ]
+} satisfies Record<CursorState, [number, number]>
+
+/** Blink cadence in ms, or null for states that never blink. */
+const BLINK = {
+  sleeping: null,
+  waking: null,
+  idle: [
+    6000,
+    14000
+  ],
+  listening: [
+    3000,
+    7000
+  ],
+  thinking: [
+    3500,
+    7000
+  ],
+  searching: [
+    1600,
+    4000
+  ],
+  working: [
+    2800,
+    5500
+  ],
+  excited: [
+    2000,
+    4000
+  ],
+  surprised: [
+    1800,
+    3500
+  ],
+  suspicious: [
+    4500,
+    8000
+  ],
+  angry: [
+    3500,
+    7000
+  ],
+  drowsy: null,
+  happy: [
+    2500,
+    5000
+  ],
+  curious: [
+    2500,
+    5500
+  ],
+  confused: [
+    2800,
+    5500
+  ],
+  bored: [
+    4000,
+    8000
+  ],
+  proud: [
+    3500,
+    7000
+  ],
+  shy: [
+    3000,
+    6000
+  ],
+  sad: [
+    4000,
+    8000
+  ],
+  laughing: [
+    2500,
+    5000
+  ],
+  scared: [
+    1200,
+    3000
+  ],
+  playful: [
+    2000,
+    4500
+  ],
+  celebrate: [
+    2200,
+    4500
+  ],
+  orbit: null,
+  radar: null,
+  progress: null,
+  spawning: null,
+  humming: [
+    4000,
+    8000
+  ],
+  loading: null,
+  dictating: null,
+  sending: null,
+  receiving: null,
+  uploading: null,
+  writing: null,
+  notifying: [
+    2000,
+    4000
+  ],
+  alerting: null,
+  bouncing: null,
+  dragging: [
+    2200,
+    4500
+  ],
+  "powering-down": null
+} satisfies Record<CursorState, [number, number] | null>
+
+/** Grouping, for pickers and docs. */
+export const STATE_GROUPS = {
+  "Cycle de vie": [
+    "sleeping",
+    "waking",
+    "idle",
+    "listening",
+    "thinking",
+    "searching",
+    "working"
+  ],
+  "Réactions": [
+    "excited",
+    "surprised",
+    "suspicious",
+    "angry",
+    "drowsy",
+    "happy",
+    "curious",
+    "confused",
+    "bored",
+    "proud",
+    "shy",
+    "sad",
+    "laughing",
+    "scared",
+    "playful",
+    "celebrate"
+  ],
+  "Morphes agent": [
+    "orbit",
+    "radar",
+    "progress"
+  ],
+  "Cycle produit": [
+    "spawning",
+    "humming",
+    "loading",
+    "dictating",
+    "writing",
+    "sending",
+    "receiving",
+    "uploading",
+    "notifying",
+    "alerting",
+    "dragging",
+    "bouncing",
+    "powering-down"
+  ]
+} satisfies Record<string, CursorState[]>
+
+const isCursorState = (state: string): state is CursorState => state in POOLS
+export const CURSOR_STATES = Object.keys(POOLS).filter(isCursorState)
+
+/* ------------------------------------------------------------------- maths */
+
+const clamp = (v: number, min: number, max: number) => Math.max(min, Math.min(max, v))
+const noTimestamp = (): number | null => null
+
+const toPath = (ring: Ring) =>
+  'M' + ring.map(p => p[0].toFixed(2) + ' ' + p[1].toFixed(2)).join('L') + 'Z'
+
+const clone = (rings: Ring[]): Ring[] =>
+  rings.map(r => r.map((p): [number, number] => [p[0], p[1]]))
+
+/** Ring centroid — same computation cursor-face-data.ts uses for mouthFrame, needed here too for eye projection. */
+const ringCentre = (ring: Ring): [number, number] => {
+  let x = 0
+  let y = 0
+  for (const p of ring) {
+    x += p[0]
+    y += p[1]
+  }
+  return [x / ring.length, y / ring.length]
+}
+
+export function mouthPath(frame: { x: number; y: number; angle: number }, spec: number[]) {
+  const ca = Math.cos(frame.angle)
+  const sa = Math.sin(frame.angle)
+  const at = (lx: number, ly: number): [number, number] => [
+    frame.x + lx * ca - ly * sa,
+    frame.y + lx * sa + ly * ca,
+  ]
+  const a = at(-spec[0], 0)
+  const c = at(0, spec[1])
+  const b = at(spec[0], 0)
+  return (
+    'M' + a[0].toFixed(2) + ' ' + a[1].toFixed(2) +
+    ' Q' + c[0].toFixed(2) + ' ' + c[1].toFixed(2) +
+    ' ' + b[0].toFixed(2) + ' ' + b[1].toFixed(2)
+  )
+}
+
+/** Face-space transform placing the face inside a silhouette. */
+export const anchorTransform = (a: { x: number; y: number; scale: number }) =>
+  `translate(${a.x} ${a.y}) scale(${a.scale}) translate(${-FACE_CENTRE[0]} ${-FACE_CENTRE[1]})`
+
+/** Overshooting ease, so a pop-in lands with a little life instead of stopping dead. */
+const easeOutBack = (t: number) => {
+  const c = 1.7
+  const u = t - 1
+  return 1 + (c + 1) * u * u * u + c * u * u
+}
+
+const easeInOut = (t: number) => (t < 0.5 ? 2 * t * t : 1 - 2 * (1 - t) * (1 - t))
+
+/**
+ * Builds the body's transform for this frame.
+ *
+ * `elapsed` is time since the state was entered, which is what one-shot entrances need;
+ * loops read it too so every mascot on a page doesn't pulse in lockstep.
+ */
+export function bodyTransform(motion: BodyMotion, elapsed: number, strength: number): string {
+  if (strength <= 0) return ''
+  const centre = FACE_BOX / 2
+  const ground = FACE_BOX
+  const wave = (period: number, phase = 0) => Math.sin((elapsed / period) * Math.PI * 2 + phase)
+
+  let dx = 0
+  let dy = 0
+  let rotation = motion.tilt ? motion.tilt * strength : 0
+  let scale = 1
+  let sx = 1
+  let sy = 1
+
+  if (motion.bob) {
+    const [amplitude, period] = motion.bob
+    const p = wave(period)
+    dy -= amplitude * strength * p
+    if (motion.squash) {
+      // Squash at the bottom of the arc, stretch at the top. Volume roughly conserved.
+      const amount = motion.squash * strength * Math.max(0, -p)
+      sy = 1 - amount * 0.5
+      sx = 1 + amount * 0.5
+    }
+  }
+  if (motion.circle) {
+    const [radius, period] = motion.circle
+    dx += radius * strength * wave(period)
+    dy += radius * strength * wave(period, Math.PI / 2)
+  }
+  if (motion.sway) {
+    const [degrees, period] = motion.sway
+    rotation += degrees * strength * wave(period)
+  }
+  if (motion.pulse) {
+    const [fraction, period] = motion.pulse
+    scale *= 1 + fraction * strength * wave(period)
+  }
+  if (motion.jitter) {
+    const [amplitude, period] = motion.jitter
+    // Two incommensurate waves read as nervous rather than metronomic.
+    dx += amplitude * strength * wave(period)
+    dy += amplitude * strength * wave(period * 0.63, 1.1)
+  }
+  if (motion.enter) {
+    const [from, duration] = motion.enter
+    const t = elapsed / duration
+    scale *= t >= 1 ? 1 : from + (1 - from) * easeOutBack(Math.max(t, 0))
+  }
+  if (motion.settle !== undefined) {
+    const t = Math.min(Math.max(elapsed / SETTLE_MS, 0), 1)
+    scale *= 1 + (motion.settle - 1) * easeInOut(t) * strength
+  }
+
+  const parts: string[] = []
+  if (dx || dy) parts.push(`translate(${dx.toFixed(2)} ${dy.toFixed(2)})`)
+  if (rotation) parts.push(`rotate(${rotation.toFixed(2)} ${centre} ${centre})`)
+  if (scale !== 1) {
+    parts.push(`translate(${centre} ${centre}) scale(${scale.toFixed(4)}) translate(${-centre} ${-centre})`)
+  }
+  if (sx !== 1 || sy !== 1) {
+    // Squash pivots on the ground, not the middle — otherwise it floats instead of landing.
+    parts.push(`translate(${centre} ${ground}) scale(${sx.toFixed(4)} ${sy.toFixed(4)}) translate(${-centre} ${-ground})`)
+  }
+  return parts.join(' ')
+}
+
+/* --------------------------------------------------------------- component */
+
+
+export interface CursorOptions {
+  state: CursorState
+  paused: boolean
+  color: string
+  silhouette: CursorSilhouette
+  gaze?: { x?: number; y?: number }
+  expression?: number
+  fixedTime?: number
+}
+type Engine = ReturnType<typeof newEngine>
+function newEngine() {
+  return { current: clone(EXPRESSIONS[0]), target: EXPRESSIONS[0], currentMouth: MOUTHS[0].slice(), targetMouth: MOUTHS[0], currentGaze: [...GAZE[0]], targetGaze: [...GAZE[0]], expression: 0, morph: 1, velocity: 0, blinkStart: noTimestamp(), spinStart: noTimestamp(), spinDuration: 900, last: 0, stateStart: 0, lastState: 'idle' as CursorState, lastBodyTransform: '',
+    props: {state: 'idle' as CursorState, expression: undefined as number | undefined, gaze: undefined as {x?:number;y?:number}|undefined, turn:0, spring:7, eyeScale:1, paused:true, lookAround:0, motionStrength:0, effects:true, glyphs:true} }
+}
+let nextId = 0
+export function mountCursorAvatar(svg: SVGSVGElement, initial: CursorOptions) {
+  const uid = `maus-${++nextId}`
+  const engine = { current: newEngine() }
+  const paintRef = { current: initial.color }
+  const node = <T extends Element>(id: string) => ({ current: svg.querySelector<T>(`[data-part="${id}"]`)! })
+  svg.setAttribute('viewBox', VIEW_BOX)
+  svg.innerHTML = `<defs><clipPath id="${uid}-clip" data-part="clip" /></defs><g data-part="trails"/><g data-part="bodyGroup"><g data-part="bodyContent"><g data-part="outline"/><g clip-path="url(#${uid}-clip)"><g data-part="face"><path data-part="eye0" fill="white"/><path data-part="eye1" fill="white"/><path data-part="mouth" fill="none" stroke="white" stroke-width="${MOUTH_STROKE}" stroke-linecap="round"/></g></g></g><g data-part="glyph" opacity="0"/></g><g data-part="confetti"/>`
+  const eye0=node<SVGPathElement>('eye0'), eye1=node<SVGPathElement>('eye1'), mouth=node<SVGPathElement>('mouth')
+  const bodyGroup=node<SVGGElement>('bodyGroup'), bodyContent=node<SVGGElement>('bodyContent'), trailLayer=node<SVGGElement>('trails'), confettiLayer=node<SVGGElement>('confetti'), glyphLayer=node<SVGGElement>('glyph')
+  const selectExpression = (index:number, immediate = false) => {
+    const e=engine.current, i=((index % EXPRESSION_COUNT)+EXPRESSION_COUNT)%EXPRESSION_COUNT
+    if (i === e.expression && e.morph >= 1 && !immediate) return
+    e.current=displayed(e);e.currentMouth=displayedMouth(e);e.currentGaze=displayedGaze(e)
+    e.target=EXPRESSIONS[i];e.targetMouth=MOUTHS[i];e.targetGaze=GAZE[i];e.expression=i;e.morph=immediate?1:0;e.velocity=0
+  }
+      const draw = (e: Engine, now: number, spinTurn: number) => {
+        const p = e.props
+        // Re-apply a fraction of this expression's own look-direction.
+        const g = displayedGaze(e)
+        const look = p.lookAround ?? 0.35
+        const ox = g[0] * look
+        const oy = g[1] * look
+        const rings = displayed(e).map(ring =>
+          ring.map((pt): [number, number] => [pt[0] + ox, pt[1] + oy])
+        )
+        const gx = clamp(p.gaze?.x ?? 0, -1, 1) * GAZE_TRAVEL.x
+        const gy = clamp(p.gaze?.y ?? 0, -1, 1) * GAZE_TRAVEL.y
+        const radians = (((p.turn ?? 0) + spinTurn) * Math.PI) / 180
+        const base = p.eyeScale ?? 1
+        const blink = blinkScale(e, now)
+
+        rings.forEach((ring, index) => {
+          const el = index === 0 ? eye0.current : eye1.current
+          if (!el) return
+          const c = ringCentre(ring)
+          const baseLongitude = Math.asin(clamp((c[0] - SPHERE_C) / SPHERE_R, -1, 1))
+          const longitude = baseLongitude + radians
+          const depth = Math.cos(longitude)
+          const perspective = Math.max(depth, 0.02) / Math.max(Math.cos(baseLongitude), 0.02)
+          el.setAttribute('d', toPath(ring))
+          el.setAttribute(
+            'transform',
+            `translate(${(SPHERE_C + SPHERE_R * Math.sin(longitude) + gx).toFixed(2)} ${(
+              c[1] + gy
+            ).toFixed(2)}) scale(${clamp(perspective * base, 0.02, 2.4).toFixed(4)} ${clamp(
+              blink * base,
+              0.02,
+              2.4
+            ).toFixed(4)}) translate(${(-c[0]).toFixed(2)} ${(-c[1]).toFixed(2)})`
+          )
+          el.style.opacity = depth > 0.02 ? '1' : '0'
+        })
+
+        // Mouth: same sphere projection as the eyes, but blinking never touches it.
+        const mouthEl = mouth.current
+        if (mouthEl) {
+          const spec = displayedMouth(e)
+          const frameGeom = mouthFrame(rings, spec)
+          const baseLongitude = Math.asin(clamp((frameGeom.x - SPHERE_C) / SPHERE_R, -1, 1))
+          const longitude = baseLongitude + radians
+          const depth = Math.cos(longitude)
+          const perspective = Math.max(depth, 0.02) / Math.max(Math.cos(baseLongitude), 0.02)
+          mouthEl.setAttribute('d', mouthPath(frameGeom, spec))
+          mouthEl.setAttribute(
+            'transform',
+            `translate(${(SPHERE_C + SPHERE_R * Math.sin(longitude) + gx).toFixed(2)} ${(
+              frameGeom.y + gy
+            ).toFixed(2)}) scale(${clamp(perspective, 0.02, 2.4).toFixed(4)} 1) translate(${(
+              -frameGeom.x
+            ).toFixed(2)} ${(-frameGeom.y).toFixed(2)})`
+          )
+          mouthEl.style.opacity = depth > 0.02 ? '1' : '0'
+        }
+
+        // The body. One-shot entrances need time since the state began, so track that here
+        // rather than in an effect — the loop already has the clock.
+        const bodyEl = bodyGroup.current
+        if (bodyEl) {
+          if (p.state !== e.lastState) {
+            e.lastState = p.state
+            e.stateStart = now
+          }
+          const transform = bodyTransform(
+            MOTION[p.state] ?? {},
+            now - e.stateStart,
+            p.motionStrength ?? 1
+          )
+          if (transform !== e.lastBodyTransform) {
+            e.lastBodyTransform = transform
+            if (transform) bodyEl.setAttribute('transform', transform)
+            else bodyEl.removeAttribute('transform')
+          }
+        }
+
+        updateEffects({
+          trails: trailLayer.current,
+          confetti: confettiLayer.current,
+          glyph: glyphLayer.current,
+          bodyContent: bodyContent.current,
+          state: p.state,
+          elapsed: now - e.stateStart,
+          strength: p.motionStrength ?? 1,
+          paint: paintRef.current,
+          showEffects: p.effects !== false,
+          showGlyphs: p.glyphs !== false,
+        })
+      }
+
+
+  let options=initial, frame=0, blinkTimer: ReturnType<typeof setTimeout>|undefined, expressionTimer:ReturnType<typeof setTimeout>|undefined, disposed=false
+  function stop() { cancelAnimationFrame(frame);frame=0;clearTimeout(blinkTimer);clearTimeout(expressionTimer) }
+  function armBlink() {
+    const range=BLINK[options.state]
+    if(options.paused||options.fixedTime!==undefined||!range)return
+    blinkTimer=setTimeout(()=>{engine.current.blinkStart=performance.now();armBlink()},range[0]+Math.random()*(range[1]-range[0]))
+  }
+  function armExpression() {
+    if(options.paused||options.fixedTime!==undefined||options.expression!==undefined)return
+    const [lo,hi]=EXPR_CADENCE[options.state]
+    expressionTimer=setTimeout(()=>{const pool=POOLS[options.state].filter(i=>i!==engine.current.expression);selectExpression(pool.length?pool[Math.floor(Math.random()*pool.length)]:POOLS[options.state][0]);armExpression()},lo+Math.random()*(hi-lo))
+  }
+  function step(now:number) {
+    frame=0
+    if(disposed||options.paused||options.fixedTime!==undefined)return
+    const e=engine.current, dt=Math.min((now-e.last)/1000,.1);e.last=now
+    e.velocity+=(-14*e.velocity-49*(e.morph-1))*dt;e.morph+=e.velocity*dt
+    if(!Number.isFinite(e.morph)){e.morph=1;e.velocity=0}
+    draw(e,now,0);frame=requestAnimationFrame(step)
+  }
+  let lastBody = '', lastClip = ''
+  function update(next:CursorOptions) {
+    const changed=options.state!==next.state||options.expression!==next.expression
+    const resume=options.paused!==next.paused||options.fixedTime!==next.fixedTime
+    options=next
+    const e=engine.current, now=performance.now();paintRef.current=next.color
+    e.props={...e.props,state:next.state,expression:next.expression,gaze:next.gaze,paused:next.paused,motionStrength:1}
+    const outline=node<SVGGElement>('outline').current, clip=node<SVGClipPathElement>('clip').current, face=node<SVGGElement>('face').current
+    const body=next.silhouette.body.replace(/\{\{GRADIENT\}\}/g,next.color)
+    if(lastBody!==body){outline.innerHTML=body;lastBody=body}
+    if(lastClip!==next.silhouette.clip){clip.innerHTML=next.silhouette.clip;lastClip=next.silhouette.clip}
+    outline.setAttribute('transform',next.silhouette.fit||'');clip.setAttribute('transform',next.silhouette.fit||'');face.setAttribute('transform',anchorTransform(next.silhouette.anchor))
+    if(changed||next.paused||e.last===0)selectExpression(next.expression??POOLS[next.state][0],next.paused||e.last===0)
+    if(changed||e.last===0){e.stateStart=now;e.lastState=next.state}
+    e.last=now
+    if(changed||resume||!frame){stop();armBlink();armExpression()}
+    draw(e,next.paused ? e.stateStart+(next.fixedTime??0) : next.fixedTime===undefined?now:e.stateStart+next.fixedTime,0)
+    if(!next.paused&&next.fixedTime===undefined&&!frame)frame=requestAnimationFrame(step)
+  }
+  update(initial)
+  return {update,dispose(){disposed=true;stop();svg.replaceChildren()}}
+}
+function displayed(e: { current: Ring[]; target: Ring[]; morph: number }): Ring[] {
+  const m = clamp(e.morph, 0, 1)
+  return e.current.map((ring, eye) =>
+    ring.map((p, i): [number, number] => [
+      p[0] + (e.target[eye][i][0] - p[0]) * m,
+      p[1] + (e.target[eye][i][1] - p[1]) * m,
+    ])
+  )
+}
+
+function displayedMouth(e: { currentMouth: number[]; targetMouth: number[]; morph: number }) {
+  const m = clamp(e.morph, 0, 1)
+  return e.currentMouth.map((v, i) => v + (e.targetMouth[i] - v) * m)
+}
+
+function displayedGaze(e: { currentGaze: number[]; targetGaze: number[]; morph: number }) {
+  const m = clamp(e.morph, 0, 1)
+  return e.currentGaze.map((v, i) => v + (e.targetGaze[i] - v) * m)
+}
+
+function blinkScale(e: { blinkStart: number | null }, now: number) {
+  if (e.blinkStart === null) return 1
+  const t = (now - e.blinkStart) / 320
+  if (t >= 1) {
+    e.blinkStart = null
+    return 1
+  }
+  // Fast close, slower open.
+  return Math.max(t < 0.42 ? 1 - t / 0.42 : (t - 0.42) / 0.58, 0.04)
+}
+

+ 154 - 0
src/client/components/common/maus/cursor-face-data.ts

@@ -0,0 +1,154 @@
+// Adapted from OpenMausBot (3a84701), Apache-2.0. See THIRD_PARTY_NOTICES.md.
+/**
+ * The mascot's face tables — 25 expressions x 2 eye rings x 48 points, a mouth
+ * per expression, and the face-space constants every coordinate is expressed in.
+ *
+ * Split out of CursorAvatar.tsx so `scripts/gen-mascot-bodies.ts` can import it
+ * from plain Node: the renderer is JSX and `--experimental-strip-types` cannot
+ * load it, but the generator needs exactly these numbers to solve where a face
+ * fits inside a new silhouette. The phone already draws this line in the same
+ * place (MausFaceData.swift vs MausAvatar.swift).
+ */
+
+/** The face box every coordinate in this file is expressed in. */
+export const FACE_BOX = 228.541
+
+/** Face-space centre the expressions are normalised around. */
+export const FACE_CENTRE: [number, number] = [120, 122.5]
+
+/** How far pointer gaze may pull the eyes, in face-space units. */
+export const GAZE_TRAVEL = { x: 13.2, y: 8.4 }
+
+/* ------------------------------------------------------------- expressions */
+
+export type Ring = [number, number][]
+
+/** 25 expressions × 2 eyes × 48 points, as "x,y x,y…|x,y x,y…". */
+const ENCODED: string[] = [
+  "130.36,45.98 132.71,46.19 134.98,46.81 137.11,47.83 138.97,49.28 140.47,51.09 141.68,53.12 142.73,55.23 143.76,57.36 144.78,59.49 145.79,61.62 146.79,63.76 147.76,65.91 148.71,68.07 149.63,70.25 150.52,72.43 151.37,74.63 151.99,76.91 152.1,79.26 151.64,81.57 150.59,83.68 149.04,85.45 147.1,86.78 144.9,87.62 142.56,87.93 140.22,87.71 137.98,86.99 135.93,85.82 134.17,84.24 132.78,82.34 131.69,80.25 130.77,78.08 129.87,75.89 128.94,73.72 128,71.56 127.03,69.4 126.05,67.26 125.05,65.12 124.03,62.99 122.93,60.9 121.87,58.79 121.03,56.59 120.72,54.26 121.1,51.93 122.15,49.83 123.75,48.1 125.76,46.89 128.01,46.19|176.61,37.08 178.72,37.59 180.7,38.48 182.52,39.65 184.2,41.03 185.71,42.59 187.03,44.31 188.2,46.14 189.26,48.03 190.27,49.96 191.26,51.89 192.23,53.84 193.16,55.8 194.05,57.78 194.92,59.77 195.74,61.78 196.53,63.8 197.27,65.84 197.97,67.9 198.47,70.01 198.63,72.18 198.4,74.33 197.58,76.33 195.95,77.72 193.83,78.08 191.71,77.65 189.76,76.69 188.03,75.38 186.53,73.82 185.28,72.05 184.25,70.13 183.4,68.14 182.63,66.11 181.87,64.07 181.07,62.05 180.25,60.04 179.39,58.05 178.49,56.07 177.57,54.1 176.61,52.15 175.62,50.22 174.59,48.31 173.53,46.41 172.54,44.48 171.86,42.42 171.76,40.26 172.62,38.3 174.45,37.19",
+  "86.14,124.79 89.16,124.92 92.06,125.76 94.68,127.27 96.86,129.36 98.48,131.92 99.51,134.76 100.12,137.73 100.56,140.72 101.01,143.72 101.52,146.71 102.06,149.69 102.62,152.67 103.2,155.64 103.81,158.61 104.44,161.57 105.11,164.52 105.81,167.47 106.49,170.42 106.72,173.44 106.01,176.37 104.37,178.9 102.03,180.81 99.26,181.99 96.27,182.46 93.25,182.25 90.34,181.43 87.65,180.04 85.32,178.11 83.51,175.7 82.37,172.9 81.66,169.96 80.98,167 80.32,164.05 79.69,161.08 79.08,158.12 78.5,155.14 77.94,152.16 77.4,149.18 76.89,146.2 76.4,143.21 75.93,140.21 75.56,137.21 75.7,134.19 76.6,131.3 78.22,128.76 80.47,126.74 83.18,125.4|147.23,112.87 150.24,113.13 153.07,114.19 155.53,115.94 157.46,118.26 158.73,121.01 159.53,123.93 160.21,126.88 160.88,129.84 161.52,132.8 162.15,135.76 162.74,138.73 163.32,141.71 163.87,144.69 164.39,147.67 164.9,150.66 165.39,153.65 165.8,156.65 165.76,159.67 164.98,162.59 163.56,165.26 161.61,167.58 159.23,169.45 156.52,170.77 153.57,171.46 150.55,171.4 147.67,170.52 145.19,168.79 143.45,166.33 142.57,163.44 142.08,160.45 141.61,157.46 141.11,154.47 140.59,151.48 140.04,148.5 139.47,145.53 138.87,142.56 138.25,139.59 137.6,136.63 136.94,133.68 136.25,130.72 135.59,127.77 135.47,124.75 136.07,121.78 137.34,119.04 139.19,116.65 141.53,114.74 144.26,113.44",
+  "104.84,104.08 109.39,104.89 113.63,106.73 117.32,109.5 120.25,113.08 122.22,117.25 123.15,121.77 122.92,126.39 121.86,130.89 120.61,135.35 119.37,139.81 118.15,144.27 116.94,148.74 115.74,153.21 114.56,157.69 113.39,162.17 112.24,166.65 111.13,171.15 110.04,175.65 108.89,180.13 106.98,184.33 103.8,187.67 99.75,189.87 95.25,190.93 90.63,190.93 86.11,189.97 81.86,188.16 78.05,185.54 74.89,182.18 72.62,178.16 71.54,173.67 71.94,169.08 73.01,164.57 74.13,160.08 75.26,155.59 76.42,151.11 77.59,146.63 78.77,142.15 79.97,137.68 81.18,133.22 82.41,128.75 83.65,124.29 84.87,119.83 86.33,115.43 88.67,111.46 91.9,108.15 95.83,105.72 100.23,104.35|174.26,115.55 178.59,116.54 182.37,118.86 185.31,122.2 187.23,126.21 188.12,130.57 188.06,135.02 187.52,139.44 186.86,143.85 186.11,148.25 185.25,152.62 184.3,156.98 183.24,161.31 182.07,165.61 180.79,169.88 179.39,174.12 177.89,178.31 176.27,182.47 174.47,186.55 172.17,190.35 169.21,193.68 165.73,196.46 161.84,198.64 157.66,200.16 153.27,200.91 148.83,200.69 144.65,199.19 141.47,196.14 140.18,191.91 140.82,187.53 142.46,183.38 144.16,179.26 145.73,175.09 147.18,170.87 148.51,166.62 149.73,162.33 150.84,158.01 151.84,153.67 152.74,149.3 153.54,144.91 154.24,140.51 154.85,136.1 155.58,131.7 156.93,127.46 159.15,123.6 162.12,120.29 165.73,117.68 169.84,116",
+  "44.72,103.35 47.95,103.93 51.04,105.05 53.93,106.62 56.58,108.57 58.97,110.83 61.09,113.35 62.93,116.07 64.5,118.97 65.79,121.99 66.8,125.13 67.52,128.34 67.94,131.6 68.08,134.89 67.9,138.17 67.4,141.42 66.56,144.6 65.33,147.66 63.71,150.52 61.69,153.11 59.3,155.36 56.54,157.16 53.51,158.42 50.29,159.07 47,159.09 43.76,158.5 40.67,157.39 37.78,155.82 35.13,153.87 32.75,151.6 30.63,149.09 28.78,146.36 27.21,143.47 25.92,140.44 24.92,137.31 24.2,134.1 23.77,130.84 23.64,127.55 23.81,124.27 24.31,121.01 25.16,117.84 26.38,114.78 28,111.92 30.02,109.33 32.42,107.08 35.17,105.28 38.21,104.02 41.43,103.37|114.62,83.64 118.31,83.88 121.94,84.6 125.44,85.77 128.77,87.38 131.86,89.4 134.67,91.81 137.13,94.56 139.2,97.62 140.85,100.93 142.05,104.42 142.79,108.05 143.05,111.73 142.83,115.42 142.13,119.05 140.95,122.55 139.34,125.88 137.3,128.96 134.88,131.75 132.11,134.2 129.05,136.27 125.75,137.93 122.25,139.13 118.63,139.86 114.94,140.1 111.25,139.86 107.63,139.14 104.12,137.97 100.8,136.36 97.7,134.34 94.9,131.93 92.44,129.18 90.36,126.12 88.72,122.81 87.51,119.31 86.78,115.69 86.52,112.01 86.74,108.32 87.44,104.69 88.61,101.18 90.23,97.86 92.27,94.78 94.69,91.99 97.45,89.54 100.51,87.47 103.82,85.81 107.31,84.61 110.94,83.88",
+  "90.44,99.25 93.11,99.26 95.74,99.79 98.37,100.3 101,100.82 103.64,101.33 106.27,101.85 108.9,102.37 111.53,102.89 114.16,103.41 116.79,103.94 119.42,104.47 122.06,105 124.69,105.53 127.32,106.06 129.94,106.6 132.57,107.14 135.2,107.68 137.65,108.73 139.5,110.65 140.44,113.14 140.36,115.81 139.35,118.28 137.48,120.18 135,121.14 132.32,121.11 129.7,120.56 127.08,120 124.45,119.46 121.82,118.93 119.19,118.4 116.56,117.87 113.93,117.35 111.29,116.83 108.66,116.32 106.02,115.82 103.39,115.31 100.76,114.79 98.12,114.28 95.49,113.75 92.86,113.22 90.23,112.68 87.77,111.66 85.93,109.74 85.01,107.24 85.06,104.57 86.07,102.1 87.96,100.22|156.6,112.56 158.91,112.66 161.18,113.15 163.45,113.64 165.72,114.13 167.99,114.62 170.26,115.12 172.53,115.62 174.8,116.12 177.07,116.62 179.34,117.13 181.6,117.64 183.87,118.15 186.14,118.67 188.4,119.18 190.67,119.7 192.94,120.2 195.12,120.96 196.7,122.63 197.45,124.82 197.53,127.14 197.07,129.41 196.08,131.51 194.49,133.18 192.33,133.97 190.04,133.68 187.79,133.09 185.52,132.57 183.26,132.05 180.99,131.54 178.72,131.03 176.45,130.53 174.18,130.03 171.91,129.54 169.64,129.05 167.37,128.56 165.1,128.07 162.83,127.58 160.56,127.08 158.29,126.57 156.02,126.05 153.9,125.14 152.26,123.51 151.32,121.4 151.08,119.1 151.53,116.82 152.65,114.8 154.41,113.31",
+  "79.94,130.44 83.08,130.57 86.11,131.41 88.89,132.89 91.27,134.94 93.15,137.46 94.39,140.35 95.11,143.42 95.65,146.52 96.2,149.62 96.78,152.72 97.39,155.81 98.04,158.89 98.72,161.97 99.43,165.04 100.18,168.1 100.97,171.15 101.81,174.18 102.64,177.22 103.01,180.34 102.35,183.4 100.66,186.04 98.19,187.98 95.28,189.16 92.17,189.6 89.03,189.37 85.99,188.56 83.16,187.2 80.63,185.33 78.55,182.97 77.05,180.21 76.1,177.21 75.28,174.17 74.5,171.11 73.74,168.06 73.02,164.99 72.33,161.92 71.68,158.83 71.06,155.75 70.47,152.65 69.91,149.55 69.37,146.45 68.97,143.32 69.12,140.18 70.04,137.18 71.7,134.51 74.03,132.41 76.86,131.04|168.95,136.28 171.31,136.97 172.99,138.76 173.66,141.13 173.36,143.58 172.24,145.79 170.45,147.49 168.22,148.58 165.89,149.43 163.57,150.31 161.24,151.18 158.9,152.02 156.56,152.85 154.21,153.65 151.85,154.44 149.48,155.2 147.11,155.94 144.73,156.65 142.34,157.34 139.95,158.01 137.55,158.66 135.14,159.27 132.73,159.86 130.31,160.43 127.84,160.54 125.53,159.69 123.77,157.96 122.89,155.66 123.09,153.21 124.36,151.09 126.34,149.61 128.69,148.83 131.1,148.24 133.51,147.62 135.91,146.98 138.31,146.32 140.7,145.65 143.08,144.95 145.46,144.23 147.83,143.49 150.2,142.73 152.56,141.95 154.91,141.15 157.25,140.32 159.59,139.48 161.92,138.61 164.24,137.72 166.54,136.78",
+  "119.79,105.35 121.94,105.42 124.03,105.87 126,106.72 127.77,107.93 129.27,109.47 130.45,111.26 131.29,113.23 131.73,115.33 131.79,117.47 131.53,119.6 131.11,121.71 130.66,123.81 130.27,125.92 129.88,128.03 129.49,130.14 129.07,132.25 128.62,134.35 127.96,136.39 126.96,138.29 125.62,139.96 123.99,141.36 122.13,142.43 120.11,143.15 118,143.5 115.85,143.45 113.75,143 111.79,142.15 110.03,140.93 108.54,139.38 107.36,137.59 106.53,135.61 106.1,133.51 106.07,131.37 106.35,129.24 106.77,127.14 107.22,125.04 107.64,122.93 108.05,120.82 108.45,118.71 108.84,116.6 109.25,114.49 109.86,112.44 110.83,110.52 112.16,108.84 113.78,107.44 115.65,106.38 117.67,105.68|174.73,114.1 176.71,114.59 178.5,115.55 180.04,116.88 181.27,118.5 182.17,120.33 182.71,122.29 182.9,124.32 182.82,126.36 182.56,128.38 182.23,130.39 181.89,132.4 181.53,134.41 181.16,136.41 180.75,138.41 180.3,140.4 179.82,142.38 179.18,144.31 178.28,146.15 177.14,147.83 175.74,149.31 174.11,150.53 172.29,151.44 170.32,151.97 168.29,152.07 166.29,151.69 164.45,150.83 162.87,149.54 161.66,147.91 160.83,146.06 160.38,144.07 160.29,142.04 160.53,140.01 160.98,138.02 161.43,136.03 161.84,134.04 162.24,132.04 162.62,130.03 162.93,128.02 163.24,126 163.6,124 164.11,122.03 164.91,120.15 165.99,118.43 167.35,116.91 168.94,115.64 170.74,114.69 172.7,114.15",
+  "105.72,72.58 108.82,72.78 111.75,73.82 114.27,75.64 116.39,77.92 118.42,80.29 120.45,82.66 122.47,85.03 124.48,87.41 126.49,89.8 128.48,92.2 130.47,94.6 132.45,97.01 134.43,99.42 136.4,101.83 138.37,104.25 140.3,106.7 141.85,109.39 142.65,112.4 142.63,115.51 141.8,118.5 140.21,121.17 137.95,123.31 135.17,124.7 132.11,125.26 129.02,124.98 126.12,123.86 123.67,121.94 121.61,119.6 119.66,117.17 117.69,114.75 115.73,112.33 113.75,109.92 111.77,107.52 109.77,105.12 107.77,102.73 105.76,100.34 103.75,97.96 101.72,95.59 99.68,93.23 97.63,90.89 95.91,88.3 94.95,85.34 94.92,82.24 95.84,79.27 97.56,76.68 99.9,74.63 102.67,73.22|176.73,63.75 179.52,64.43 182.02,65.85 184.15,67.78 185.85,70.1 187.08,72.7 187.76,75.5 187.76,78.37 187.23,81.2 186.58,84.01 185.89,86.8 185.16,89.59 184.39,92.36 183.59,95.13 182.76,97.89 181.88,100.64 180.98,103.37 180.02,106.09 179.03,108.79 177.99,111.48 176.9,114.15 175.5,116.66 173.51,118.72 171,120.11 168.18,120.66 165.34,120.29 162.75,119.04 160.62,117.12 159.02,114.73 158,112.04 157.63,109.19 157.98,106.34 158.83,103.59 159.86,100.9 160.87,98.2 161.85,95.49 162.79,92.76 163.7,90.03 164.57,87.29 165.41,84.53 166.21,81.76 166.98,78.99 167.7,76.2 168.38,73.4 169,70.58 169.79,67.82 171.41,65.46 173.88,64.03",
+  "64.12,83.43 66.69,84.08 68.9,85.53 70.57,87.6 71.56,90.05 71.86,92.69 71.53,95.32 70.55,97.78 69.06,99.99 67.36,102.03 65.64,104.06 63.95,106.11 62.28,108.18 60.63,110.27 59,112.38 57.41,114.5 55.84,116.65 54.29,118.82 52.78,121 51.31,123.22 49.86,125.45 48.42,127.69 46.67,129.68 44.36,130.96 41.73,131.22 39.22,130.38 37.16,128.72 35.64,126.55 34.65,124.08 34.2,121.46 34.29,118.81 34.94,116.24 36.12,113.86 37.54,111.61 39.04,109.41 40.54,107.21 42.06,105.03 43.61,102.87 45.18,100.73 46.79,98.6 48.41,96.5 50.07,94.42 51.74,92.35 53.45,90.31 55.17,88.28 56.94,86.3 59.04,84.67 61.48,83.65|104.23,93.09 106.97,93.77 109.4,95.24 111.32,97.31 112.59,99.85 113.4,102.57 114.13,105.32 114.87,108.06 115.62,110.8 116.37,113.54 117.14,116.28 117.9,119.02 118.67,121.75 119.45,124.49 120.23,127.22 121.03,129.95 121.83,132.68 122.6,135.41 122.98,138.22 122.57,141.02 121.34,143.57 119.44,145.67 117.05,147.19 114.34,148.04 111.51,148.14 108.77,147.45 106.33,146.01 104.38,143.96 103.07,141.44 102.21,138.73 101.43,136 100.65,133.27 99.87,130.53 99.09,127.8 98.32,125.06 97.55,122.33 96.79,119.59 96.04,116.85 95.28,114.11 94.53,111.37 93.79,108.62 93.05,105.88 92.73,103.07 93.18,100.27 94.42,97.72 96.32,95.62 98.7,94.08 101.4,93.21",
+  "108.14,135.9 111.13,136.06 114.08,136.56 116.95,137.39 119.71,138.55 122.31,140.03 124.71,141.81 126.86,143.89 128.73,146.23 130.27,148.8 131.44,151.55 132.22,154.44 132.56,157.41 132.47,160.4 131.93,163.34 130.96,166.17 129.6,168.83 127.87,171.28 125.83,173.46 123.51,175.35 120.96,176.91 118.23,178.13 115.36,179 112.42,179.52 109.43,179.7 106.44,179.54 103.49,179.04 100.62,178.21 97.86,177.06 95.26,175.58 92.86,173.79 90.71,171.71 88.84,169.37 87.3,166.81 86.13,164.06 85.35,161.17 85.01,158.2 85.11,155.21 85.64,152.26 86.61,149.43 87.97,146.77 89.7,144.33 91.74,142.14 94.06,140.25 96.61,138.69 99.34,137.47 102.21,136.6 105.15,136.08|175.67,128.81 177.16,128.85 178.62,129.14 180.01,129.67 181.28,130.44 182.41,131.42 183.36,132.57 184.12,133.85 184.68,135.23 185.05,136.68 185.24,138.15 185.27,139.64 185.13,141.13 184.85,142.59 184.42,144.02 183.85,145.4 183.15,146.72 182.33,147.96 181.38,149.11 180.32,150.16 179.15,151.08 177.88,151.87 176.53,152.49 175.1,152.91 173.62,153.11 172.13,153.07 170.67,152.78 169.28,152.24 168.01,151.47 166.88,150.49 165.93,149.35 165.18,148.06 164.61,146.68 164.24,145.24 164.05,143.76 164.02,142.27 164.16,140.78 164.44,139.32 164.87,137.89 165.44,136.51 166.14,135.2 166.96,133.95 167.91,132.8 168.97,131.75 170.14,130.83 171.4,130.04 172.76,129.43 174.19,129.01",
+  "103.39,78.25 106.56,78.38 109.59,79.28 112.29,80.94 114.43,83.28 115.86,86.1 116.52,89.2 116.32,92.37 115.62,95.46 114.86,98.55 114.1,101.63 113.36,104.72 112.63,107.81 111.91,110.91 111.2,114.01 110.51,117.1 109.82,120.21 109.16,123.31 108.52,126.42 107.84,129.53 106.8,132.52 105.04,135.16 102.66,137.25 99.81,138.63 96.7,139.2 93.54,138.96 90.53,137.95 87.87,136.24 85.71,133.92 84.21,131.13 83.48,128.05 83.5,124.88 84,121.74 84.7,118.64 85.4,115.54 86.08,112.44 86.77,109.34 87.48,106.24 88.19,103.15 88.92,100.05 89.66,96.96 90.41,93.88 91.18,90.8 91.95,87.71 93.1,84.76 94.95,82.19 97.4,80.19 100.28,78.86|161.99,91.57 165.07,92 167.9,93.28 170.32,95.23 172.22,97.69 173.49,100.53 174.05,103.59 173.93,106.7 173.45,109.78 172.9,112.85 172.33,115.91 171.71,118.97 171.06,122.02 170.38,125.06 169.66,128.09 168.91,131.12 168.13,134.14 167.33,137.15 166.5,140.16 165.58,143.14 164.33,145.98 162.5,148.5 160.15,150.53 157.38,151.95 154.34,152.61 151.24,152.43 148.32,151.38 145.83,149.52 143.99,147.01 142.95,144.08 142.73,140.98 143.25,137.91 144.09,134.91 144.94,131.91 145.76,128.9 146.54,125.89 147.29,122.86 148.01,119.83 148.69,116.79 149.34,113.74 149.95,110.68 150.54,107.62 151.09,104.55 151.6,101.47 152.44,98.48 154,95.79 156.21,93.6 158.94,92.12",
+  "47.23,91.05 51.61,92.47 55.26,95.29 57.96,99.03 59.7,103.31 60.56,107.85 61.28,112.42 62.11,116.97 63.06,121.51 64.11,126.01 65.28,130.49 66.56,134.94 67.95,139.35 69.44,143.73 71.05,148.07 72.77,152.37 74.62,156.61 76.52,160.83 78.06,165.19 78.52,169.78 77.62,174.3 75.2,178.22 71.44,180.86 66.96,181.91 62.36,181.55 57.99,180.06 54.03,177.68 50.59,174.58 47.77,170.92 45.62,166.83 43.74,162.6 41.96,158.33 40.29,154.01 38.72,149.66 37.27,145.26 35.93,140.83 34.69,136.37 33.56,131.88 32.55,127.37 31.65,122.83 30.85,118.27 30.2,113.69 30.08,109.07 30.85,104.51 32.49,100.19 35.01,96.32 38.44,93.24 42.64,91.35|108.93,73.38 113.76,73.6 118.38,75.01 122.49,77.56 125.82,81.07 128.1,85.34 129.65,89.93 131.04,94.58 132.43,99.22 133.82,103.87 135.22,108.51 136.6,113.16 137.99,117.81 139.36,122.46 140.73,127.11 142.13,131.76 143.51,136.41 144.82,141.08 145.51,145.86 144.91,150.66 143.09,155.14 140.17,158.99 136.38,162.01 132,164.05 127.26,165.03 122.42,164.86 117.79,163.48 113.67,160.95 110.36,157.43 108.17,153.11 106.72,148.49 105.36,143.83 103.99,139.18 102.62,134.53 101.24,129.88 99.87,125.23 98.48,120.58 97.09,115.93 95.7,111.29 94.29,106.64 92.89,102 91.49,97.36 90.81,92.57 91.38,87.77 93.14,83.26 96.02,79.37 99.79,76.34 104.18,74.32",
+  "102.86,129.56 106.39,129.96 109.85,130.76 113.21,131.94 116.4,133.5 119.39,135.42 122.13,137.69 124.56,140.28 126.64,143.16 128.33,146.29 129.59,149.61 130.37,153.08 130.66,156.62 130.43,160.16 129.68,163.63 128.42,166.96 126.7,170.07 124.54,172.89 122.02,175.39 119.18,177.52 116.07,179.25 112.76,180.54 109.31,181.4 105.78,181.82 102.23,181.83 98.7,181.42 95.24,180.62 91.88,179.44 88.69,177.88 85.7,175.96 82.96,173.69 80.53,171.1 78.45,168.22 76.76,165.1 75.5,161.77 74.72,158.31 74.44,154.77 74.67,151.22 75.41,147.75 76.67,144.42 78.39,141.32 80.55,138.49 83.07,135.99 85.91,133.86 89.02,132.14 92.33,130.84 95.78,129.99 99.31,129.56|180.44,141.06 183.61,141.1 186.72,141.68 189.66,142.86 192.28,144.63 194.43,146.96 196,149.71 197,152.72 197.48,155.86 197.51,159.03 197.15,162.18 196.43,165.27 195.38,168.26 194.07,171.15 192.52,173.92 190.74,176.55 188.77,179.03 186.6,181.35 184.25,183.48 181.74,185.41 179.06,187.12 176.23,188.56 173.28,189.71 170.21,190.52 167.07,190.95 163.9,190.91 160.78,190.33 157.84,189.15 155.22,187.37 153.08,185.04 151.51,182.29 150.51,179.28 150.03,176.15 150,172.98 150.36,169.83 151.07,166.74 152.12,163.74 153.43,160.85 154.99,158.09 156.76,155.46 158.74,152.98 160.91,150.66 163.25,148.53 165.77,146.59 168.45,144.89 171.27,143.45 174.23,142.3 177.29,141.48",
+  "86.02,131.33 88.42,131.69 90.54,132.87 92.12,134.71 93.03,136.97 93.17,139.39 92.41,141.69 90.78,143.48 88.59,144.52 86.19,144.97 83.78,145.34 81.37,145.72 78.96,146.09 76.54,146.45 74.13,146.81 71.72,147.16 69.3,147.5 66.88,147.84 64.47,148.16 62.05,148.47 59.62,148.76 57.2,149.03 54.77,149.27 52.34,149.49 49.91,149.68 47.53,149.28 45.52,147.92 44.04,145.99 43.12,143.74 42.85,141.32 43.44,138.97 45.04,137.17 47.33,136.4 49.76,136.2 52.19,135.96 54.62,135.7 57.04,135.43 59.46,135.15 61.89,134.85 64.31,134.54 66.72,134.22 69.14,133.89 71.56,133.54 73.97,133.19 76.38,132.83 78.79,132.45 81.2,132.06 83.61,131.67|153.42,116.84 155.92,117.69 157.78,119.56 158.72,122.03 158.73,124.68 157.84,127.18 156.08,129.15 153.71,130.33 151.13,131 148.55,131.65 145.98,132.32 143.4,132.98 140.82,133.63 138.23,134.27 135.65,134.9 133.06,135.53 130.47,136.15 127.88,136.76 125.29,137.36 122.69,137.94 120.09,138.51 117.49,139.06 114.88,139.59 112.27,140.1 109.62,140.12 107.17,139.14 105.28,137.29 104.18,134.88 104.06,132.23 105.03,129.78 106.92,127.93 109.35,126.87 111.96,126.34 114.57,125.8 117.17,125.24 119.77,124.67 122.37,124.09 124.96,123.5 127.56,122.9 130.15,122.29 132.74,121.68 135.32,121.05 137.91,120.41 140.49,119.76 143.07,119.11 145.65,118.44 148.22,117.76 150.78,117.03",
+  "83.36,65.84 86.61,66.42 89.57,67.88 92,70.12 93.67,72.97 94.46,76.17 94.31,79.47 93.61,82.71 92.8,85.92 92.02,89.14 91.27,92.36 90.56,95.6 89.88,98.84 89.23,102.09 88.62,105.34 88.03,108.61 87.51,111.88 87.01,115.15 86.5,118.42 85.65,121.62 84.11,124.54 81.9,126.99 79.13,128.79 75.98,129.8 72.68,129.91 69.47,129.13 66.6,127.49 64.24,125.18 62.49,122.38 61.42,119.25 61.08,115.97 61.36,112.67 61.87,109.4 62.45,106.13 63.01,102.87 63.61,99.61 64.24,96.36 64.9,93.11 65.6,89.87 66.33,86.64 67.09,83.42 67.89,80.2 68.73,77 69.89,73.9 71.7,71.14 74.1,68.86 76.93,67.16 80.07,66.12|119.49,99.16 121.99,99.28 124.44,99.82 126.9,100.34 129.36,100.86 131.81,101.38 134.27,101.91 136.72,102.44 139.17,102.98 141.62,103.52 144.08,104.07 146.52,104.62 148.97,105.18 151.42,105.74 153.86,106.31 156.31,106.89 158.75,107.47 161.2,108.02 163.49,109.03 165.16,110.87 165.95,113.24 165.87,115.74 164.94,118.05 163.17,119.8 160.79,120.55 158.31,120.29 155.88,119.65 153.44,119.06 150.99,118.49 148.55,117.92 146.1,117.36 143.65,116.81 141.2,116.26 138.75,115.73 136.29,115.2 133.84,114.67 131.38,114.15 128.93,113.62 126.47,113.1 124.02,112.57 121.56,112.04 119.11,111.5 116.81,110.53 115.08,108.73 114.22,106.39 114.29,103.9 115.28,101.61 117.12,99.93",
+  "168.71,85.67 170.73,86 172.64,86.75 174.37,87.85 175.88,89.24 177.11,90.88 178.04,92.71 178.71,94.65 179.21,96.64 179.64,98.65 180.05,100.66 180.45,102.67 180.83,104.69 181.18,106.71 181.5,108.74 181.77,110.78 181.88,112.82 181.71,114.87 181.22,116.86 180.41,118.74 179.27,120.45 177.83,121.91 176.12,123.03 174.2,123.74 172.16,123.99 170.13,123.75 168.21,123.04 166.49,121.93 165.03,120.48 163.88,118.79 163.02,116.93 162.49,114.95 162.17,112.92 161.85,110.89 161.51,108.87 161.14,106.84 160.77,104.83 160.37,102.81 159.96,100.8 159.52,98.79 159.24,96.76 159.3,94.71 159.71,92.7 160.48,90.8 161.58,89.08 163.01,87.61 164.73,86.49 166.67,85.83|206.57,78.24 208.3,78.8 209.77,79.87 210.99,81.22 212.01,82.73 212.85,84.34 213.53,86.03 214.08,87.77 214.54,89.54 214.95,91.31 215.34,93.09 215.7,94.88 216.04,96.67 216.36,98.46 216.64,100.26 216.89,102.07 217.11,103.88 217.25,105.7 217.26,107.52 217.1,109.33 216.75,111.12 216.16,112.84 215.3,114.44 214.04,115.75 212.34,116.35 210.58,115.96 209.11,114.89 207.97,113.48 207.1,111.88 206.44,110.18 205.97,108.42 205.62,106.63 205.35,104.83 205.11,103.02 204.85,101.22 204.55,99.42 204.22,97.63 203.87,95.84 203.49,94.05 203.09,92.28 202.67,90.5 202.32,88.71 202.1,86.91 202.1,85.08 202.32,83.28 202.77,81.51 203.54,79.86 204.82,78.59",
+  "39.78,104.3 42.64,105.01 45.03,106.74 46.73,109.15 47.75,111.93 48.35,114.83 48.81,117.76 49.31,120.68 49.87,123.6 50.48,126.5 51.15,129.39 51.86,132.27 52.63,135.13 53.44,137.98 54.3,140.82 55.19,143.65 56.13,146.46 57.1,149.26 58.1,152.05 58.86,154.92 58.96,157.87 58.18,160.72 56.43,163.09 53.84,164.48 50.9,164.62 48.08,163.75 45.58,162.16 43.51,160.05 41.89,157.57 40.67,154.87 39.67,152.08 38.73,149.26 37.81,146.44 36.94,143.61 36.11,140.76 35.34,137.9 34.61,135.03 33.92,132.14 33.28,129.24 32.7,126.34 32.16,123.42 31.67,120.5 31.23,117.57 31.14,114.61 31.65,111.69 32.74,108.94 34.47,106.54 36.89,104.86|108.97,125.73 111.9,126.2 114.63,127.37 117,129.16 118.84,131.49 119.99,134.23 120.32,137.18 119.85,140.11 118.59,142.8 116.78,145.16 114.84,147.42 112.89,149.67 110.93,151.92 108.97,154.16 107.01,156.39 105.03,158.62 103.05,160.85 101.07,163.06 99.07,165.28 97.09,167.5 95.09,169.71 93.05,171.88 90.69,173.67 87.89,174.66 84.93,174.81 82.02,174.22 79.31,173 76.92,171.24 74.99,168.98 73.7,166.3 73.26,163.37 73.74,160.45 75.2,157.86 77.17,155.63 79.18,153.43 81.18,151.23 83.17,149.02 85.16,146.8 87.14,144.58 89.11,142.35 91.08,140.11 93.04,137.87 95,135.63 96.95,133.38 98.89,131.12 100.87,128.89 103.25,127.12 106.02,126.04",
+  "115.43,87.59 118.19,88.24 120.69,89.59 122.69,91.59 124.03,94.09 124.63,96.87 124.41,99.7 123.7,102.45 122.9,105.18 122.12,107.92 121.34,110.65 120.57,113.39 119.79,116.13 119.02,118.87 118.25,121.61 117.48,124.35 116.72,127.09 115.97,129.83 115.23,132.58 114.45,135.32 113.3,137.91 111.49,140.09 109.13,141.65 106.41,142.48 103.58,142.56 100.82,141.91 98.33,140.54 96.33,138.53 94.99,136.03 94.38,133.26 94.57,130.43 95.25,127.67 96.02,124.93 96.79,122.19 97.55,119.45 98.31,116.7 99.08,113.96 99.85,111.22 100.62,108.49 101.4,105.75 102.18,103.01 102.95,100.27 103.74,97.54 104.51,94.8 105.68,92.21 107.5,90.04 109.88,88.5 112.59,87.67|155.37,78.64 158.04,79.15 160.48,80.37 162.53,82.16 164.33,84.22 166.1,86.3 167.85,88.4 169.58,90.51 171.29,92.64 172.98,94.78 174.65,96.94 176.3,99.12 177.92,101.32 179.51,103.54 181.06,105.79 182.57,108.07 184.03,110.37 185.2,112.83 185.77,115.5 185.73,118.23 185.07,120.87 183.8,123.28 181.94,125.26 179.57,126.6 176.89,127.03 174.24,126.43 171.99,124.92 170.26,122.8 168.75,120.53 167.22,118.27 165.66,116.03 164.07,113.81 162.45,111.6 160.82,109.41 159.16,107.25 157.47,105.1 155.76,102.97 154.03,100.85 152.28,98.76 150.51,96.68 148.74,94.6 147.29,92.29 146.49,89.69 146.36,86.97 146.94,84.31 148.23,81.91 150.19,80.02 152.66,78.89",
+  "92.71,76.24 95.76,76.59 98.73,77.35 101.56,78.53 104.21,80.08 106.63,81.96 108.78,84.16 110.61,86.62 112.09,89.31 113.19,92.17 113.91,95.15 114.24,98.2 114.18,101.27 113.73,104.31 112.9,107.26 111.7,110.09 110.15,112.73 108.28,115.17 106.12,117.35 103.71,119.24 101.08,120.83 98.27,122.07 95.33,122.95 92.3,123.44 89.23,123.51 86.18,123.17 83.21,122.41 80.38,121.23 77.73,119.69 75.31,117.8 73.16,115.61 71.33,113.14 69.86,110.45 68.75,107.59 68.02,104.61 67.7,101.56 67.76,98.49 68.22,95.46 69.05,92.5 70.25,89.68 71.8,87.03 73.67,84.59 75.83,82.41 78.24,80.52 80.87,78.93 83.67,77.69 86.61,76.81 89.64,76.32|157.73,102.08 159.31,102.13 160.86,102.42 162.35,102.95 163.74,103.69 165.01,104.62 166.15,105.72 167.13,106.95 167.96,108.29 168.63,109.72 169.13,111.22 169.45,112.76 169.59,114.34 169.55,115.91 169.31,117.47 168.88,118.99 168.28,120.45 167.52,121.83 166.58,123.1 165.48,124.23 164.25,125.22 162.89,126.02 161.43,126.63 159.91,127.02 158.34,127.2 156.76,127.14 155.21,126.85 153.73,126.33 152.33,125.59 151.06,124.65 149.93,123.56 148.94,122.33 148.11,120.99 147.44,119.56 146.95,118.06 146.62,116.51 146.48,114.94 146.52,113.37 146.76,111.8 147.18,110.28 147.78,108.83 148.56,107.45 149.5,106.18 150.59,105.05 151.83,104.07 153.18,103.26 154.64,102.65 156.17,102.26",
+  "57.98,112.03 61.01,112.26 63.82,113.41 66.22,115.27 68.09,117.67 69.36,120.44 70.14,123.39 70.64,126.39 71.07,129.41 71.58,132.42 72.15,135.41 72.76,138.4 73.4,141.38 74.09,144.35 74.82,147.31 75.59,150.26 76.42,153.2 77.3,156.12 78.16,159.04 78.68,162.04 78.42,165.07 77.28,167.88 75.25,170.13 72.54,171.49 69.52,171.87 66.51,171.44 63.69,170.32 61.16,168.61 59.03,166.44 57.38,163.89 56.23,161.06 55.34,158.15 54.53,155.21 53.73,152.27 52.97,149.31 52.25,146.35 51.57,143.38 50.93,140.4 50.34,137.41 49.78,134.41 49.26,131.4 48.78,128.39 48.37,125.37 48.37,122.33 49.06,119.36 50.44,116.65 52.47,114.39 55.04,112.78|117.45,101.46 120.57,101.84 123.48,103.02 125.99,104.91 127.97,107.35 129.25,110.23 130.05,113.27 130.76,116.34 131.46,119.41 132.15,122.49 132.85,125.56 133.55,128.63 134.24,131.71 134.93,134.78 135.61,137.86 136.32,140.93 137.01,144 137.66,147.09 137.87,150.22 137.25,153.3 135.83,156.1 133.73,158.45 131.15,160.23 128.22,161.38 125.11,161.83 121.98,161.5 119.05,160.37 116.52,158.51 114.57,156.05 113.37,153.14 112.64,150.08 111.95,147 111.27,143.93 110.58,140.85 109.89,137.78 109.2,134.7 108.51,131.63 107.81,128.56 107.12,125.48 106.42,122.41 105.71,119.34 105.02,116.27 104.83,113.13 105.42,110.04 106.79,107.22 108.84,104.83 111.41,103.02 114.33,101.87",
+  "113.75,120.08 118.16,120.78 122.31,122.43 125.98,124.96 128.94,128.29 131.02,132.23 132.12,136.56 132.1,141.02 131.16,145.38 129.94,149.68 128.69,153.98 127.44,158.27 126.19,162.56 124.92,166.85 123.64,171.14 122.35,175.42 121.05,179.69 119.76,183.97 118.46,188.25 117.05,192.49 114.84,196.36 111.47,199.27 107.42,201.12 103.04,201.99 98.58,201.97 94.18,201.19 89.97,199.7 86.1,197.48 82.8,194.48 80.41,190.72 79.31,186.41 79.87,182 81.24,177.74 82.57,173.47 83.88,169.2 85.19,164.92 86.48,160.64 87.75,156.36 89.02,152.07 90.28,147.78 91.53,143.49 92.77,139.19 93.97,134.88 95.5,130.69 97.93,126.95 101.16,123.87 105.02,121.64 109.3,120.36|181.19,127.71 185.4,128.03 189.23,129.79 192.19,132.8 194.06,136.59 194.87,140.74 194.76,144.98 194.21,149.18 193.55,153.37 192.79,157.55 191.91,161.69 190.91,165.82 189.8,169.91 188.57,173.97 187.22,177.99 185.72,181.96 184.07,185.87 182.26,189.7 180.26,193.44 177.85,196.92 174.93,199.99 171.62,202.64 168,204.85 164.13,206.59 160.07,207.78 155.86,208.24 151.69,207.6 148.31,205.16 147.33,201.11 148.48,197.06 150.4,193.28 152.24,189.46 153.93,185.56 155.48,181.61 156.89,177.62 158.17,173.57 159.32,169.49 160.35,165.38 161.26,161.23 162.06,157.07 162.7,152.87 163.29,148.67 164.12,144.52 165.46,140.5 167.49,136.78 170.16,133.49 173.37,130.73 177.08,128.7",
+  "52.43,111.88 55.76,112.3 59,113.21 62.08,114.57 64.96,116.3 67.61,118.36 70.01,120.71 72.16,123.3 74.02,126.1 75.61,129.06 76.9,132.17 77.89,135.38 78.56,138.68 78.89,142.02 78.86,145.39 78.45,148.72 77.63,151.98 76.39,155.11 74.74,158.03 72.67,160.68 70.2,162.96 67.38,164.79 64.28,166.08 61,166.78 57.64,166.91 54.3,166.49 51.07,165.57 47.99,164.22 45.11,162.49 42.46,160.42 40.05,158.07 37.91,155.48 36.04,152.68 34.46,149.72 33.16,146.62 32.17,143.4 31.51,140.11 31.18,136.76 31.21,133.4 31.62,130.06 32.44,126.8 33.67,123.68 35.33,120.75 37.4,118.1 39.86,115.82 42.68,114 45.79,112.71 49.07,112.01|126.89,95.19 130.56,95.44 134.16,96.17 137.64,97.36 140.94,98.99 143.98,101.06 146.73,103.5 149.13,106.29 151.14,109.37 152.72,112.69 153.86,116.19 154.54,119.8 154.74,123.47 154.45,127.14 153.67,130.74 152.43,134.2 150.76,137.48 148.69,140.51 146.25,143.27 143.48,145.69 140.43,147.75 137.14,149.39 133.66,150.59 130.06,151.32 126.39,151.56 122.72,151.31 119.11,150.59 115.63,149.4 112.34,147.76 109.29,145.7 106.54,143.26 104.14,140.47 102.13,137.39 100.55,134.07 99.41,130.57 98.73,126.96 98.53,123.28 98.83,119.62 99.6,116.02 100.84,112.56 102.51,109.28 104.59,106.25 107.03,103.49 109.79,101.07 112.84,99.01 116.13,97.36 119.61,96.16 123.22,95.43",
+  "72.34,79.33 74.93,79.64 77.52,79.94 80.12,80.25 82.71,80.57 85.3,80.91 87.88,81.26 90.47,81.63 93.05,82 95.63,82.39 98.21,82.79 100.79,83.21 103.37,83.63 105.94,84.07 108.51,84.52 111.09,84.92 113.65,85.44 115.99,86.56 117.75,88.47 118.68,90.89 118.57,93.48 117.47,95.83 115.57,97.6 113.16,98.56 110.56,98.58 108,98.1 105.43,97.64 102.85,97.19 100.28,96.76 97.7,96.33 95.12,95.93 92.54,95.53 89.96,95.14 87.38,94.77 84.79,94.4 82.2,94.06 79.62,93.72 77.02,93.41 74.43,93.11 71.84,92.81 69.24,92.5 66.78,91.69 64.99,89.83 64.25,87.35 64.5,84.76 65.58,82.39 67.38,80.53 69.74,79.45|136.83,90.25 139.33,90.71 141.8,91.34 144.28,91.93 146.75,92.53 149.22,93.14 151.69,93.77 154.15,94.4 156.62,95.05 159.08,95.71 161.53,96.38 163.98,97.07 166.43,97.76 168.88,98.47 171.32,99.2 173.75,99.94 176.19,100.67 178.49,101.74 180.18,103.62 181.12,105.98 181.31,108.51 180.69,110.96 179.21,113.01 176.98,114.19 174.46,114.13 172.02,113.41 169.58,112.66 167.14,111.93 164.7,111.22 162.25,110.52 159.8,109.84 157.34,109.17 154.88,108.52 152.42,107.88 149.95,107.26 147.48,106.64 145.01,106.03 142.54,105.42 140.06,104.81 137.59,104.21 135.12,103.61 132.78,102.62 130.96,100.86 129.93,98.55 129.77,96.02 130.49,93.59 132.06,91.61 134.31,90.46",
+  "107.05,116.19 110.26,116.71 113.26,118 115.85,119.97 117.92,122.49 119.29,125.45 120.04,128.63 120.54,131.86 121.04,135.09 121.54,138.31 122.06,141.54 122.59,144.77 123.12,147.99 123.67,151.21 124.22,154.43 124.8,157.65 125.4,160.86 125.95,164.08 126.04,167.34 125.2,170.49 123.53,173.28 121.14,175.49 118.26,177.02 115.11,177.87 111.85,178.04 108.63,177.56 105.58,176.41 102.87,174.58 100.72,172.14 99.35,169.18 98.61,166 98.04,162.78 97.47,159.57 96.91,156.35 96.36,153.13 95.83,149.9 95.3,146.68 94.78,143.45 94.27,140.22 93.76,137 93.26,133.77 92.82,130.53 92.96,127.27 93.87,124.15 95.55,121.35 97.88,119.07 100.68,117.41 103.79,116.44|189.21,121.96 191.36,122.71 192.8,124.5 193.41,126.73 193.32,129.04 192.59,131.23 191.22,133.09 189.28,134.34 187.12,135.19 184.95,136 182.78,136.83 180.61,137.63 178.43,138.43 176.24,139.2 174.05,139.95 171.85,140.69 169.64,141.4 167.43,142.09 165.21,142.76 162.98,143.42 160.75,144.05 158.51,144.66 156.27,145.25 154.02,145.81 151.72,145.98 149.57,145.18 148.01,143.49 147.25,141.31 147.31,139 148.16,136.86 149.7,135.14 151.74,134.05 153.97,133.43 156.21,132.84 158.45,132.22 160.68,131.59 162.91,130.93 165.13,130.26 167.34,129.58 169.55,128.87 171.76,128.15 173.95,127.41 176.15,126.65 178.33,125.87 180.51,125.08 182.68,124.26 184.84,123.41 186.98,122.53",
+  "98.62,121.53 100.72,121.6 102.78,122.03 104.72,122.83 106.49,123.97 108.01,125.42 109.24,127.12 110.15,129.02 110.66,131.05 110.78,133.15 110.62,135.25 110.34,137.33 110.04,139.42 109.76,141.5 109.49,143.59 109.21,145.68 108.92,147.76 108.6,149.84 108.08,151.88 107.18,153.78 105.92,155.45 104.34,156.83 102.51,157.86 100.51,158.52 98.43,158.81 96.33,158.72 94.28,158.27 92.33,157.47 90.57,156.33 89.03,154.89 87.78,153.21 86.85,151.32 86.29,149.3 86.13,147.2 86.28,145.11 86.57,143.02 86.9,140.94 87.2,138.86 87.49,136.78 87.79,134.69 88.08,132.61 88.4,130.53 88.96,128.5 89.88,126.61 91.15,124.94 92.73,123.56 94.56,122.52 96.54,121.84|155.31,127.29 157.36,127.43 159.33,127.99 161.14,128.96 162.7,130.29 163.95,131.92 164.84,133.77 165.35,135.75 165.49,137.8 165.35,139.85 165.07,141.89 164.79,143.92 164.53,145.96 164.26,148 163.97,150.04 163.65,152.06 163.27,154.09 162.7,156.06 161.83,157.92 160.68,159.62 159.29,161.13 157.69,162.41 155.9,163.42 153.97,164.1 151.94,164.41 149.89,164.32 147.9,163.82 146.06,162.92 144.47,161.62 143.24,159.98 142.41,158.1 142,156.09 142.01,154.04 142.32,152.01 142.64,149.98 142.96,147.95 143.25,145.92 143.51,143.88 143.73,141.83 143.97,139.79 144.27,137.76 144.74,135.76 145.53,133.87 146.62,132.13 147.98,130.59 149.57,129.28 151.35,128.27 153.28,127.58",
+]
+
+const RAW: Ring[][] = ENCODED.map(line =>
+  line.split('|').map(
+    ring =>
+      ring.split(' ').map((pair): [number, number] => {
+        const [x, y] = pair.split(',')
+        return [Number(x), Number(y)]
+      })
+  )
+)
+
+const ringCentre = (ring: Ring): [number, number] => {
+  let x = 0
+  let y = 0
+  for (const p of ring) {
+    x += p[0]
+    y += p[1]
+  }
+  return [x / ring.length, y / ring.length]
+}
+
+/**
+ * Each expression's look-direction: the eye-pair centroid relative to face centre.
+ * Split out so the resting face can look straight ahead.
+ */
+export const GAZE: [number, number][] = RAW.map(ex => {
+  const a = ringCentre(ex[0])
+  const b = ringCentre(ex[1])
+  return [(a[0] + b[0]) / 2 - FACE_CENTRE[0], (a[1] + b[1]) / 2 - FACE_CENTRE[1]]
+})
+
+/** Expressions with their look-direction removed — every one looks straight ahead. */
+export const EXPRESSIONS: Ring[][] = RAW.map((ex, i) =>
+  ex.map(ring => ring.map((p): [number, number] => [p[0] - GAZE[i][0], p[1] - GAZE[i][1]]))
+)
+
+export const EXPRESSION_COUNT = EXPRESSIONS.length
+
+/**
+ * One mouth per expression: [halfWidth, curve, gap, skew].
+ *   curve  + bows the middle down => smile (U);  - => frown
+ *   gap    clearance below the lowest eye edge
+ *   skew   extra tilt in degrees on top of the eye-pair tilt, for smirks
+ */
+export const MOUTHS: number[][] = [
+  [14,3,20,0],
+  [13,1,20,0],
+  [25,14,13,0],
+  [11,4,18,0],
+  [16,-2,22,0],
+  [15,-3,20,6],
+  [12,2,20,0],
+  [17,-7,18,0],
+  [15,5,20,0],
+  [12,3,20,0],
+  [14,1,20,0],
+  [23,12,13,0],
+  [12,2,20,0],
+  [16,-3,22,0],
+  [15,-2,20,-6],
+  [12,5,20,0],
+  [16,-4,18,0],
+  [20,11,18,0],
+  [11,2,20,0],
+  [17,8,20,0],
+  [15,5,10,0],
+  [12,4,18,0],
+  [16,-3,22,0],
+  [15,-5,20,7],
+  [11,6,20,0],
+]
+
+/** Mouth thickness, in face-space units. */
+export const MOUTH_STROKE = 7.5
+
+/**
+ * The mouth hangs off the eyes: centred under the pair, tilted with them, and pushed clear
+ * of whichever eye is tallest. It stays coherent for free as the eyes move, so only four
+ * numbers per expression have to be authored.
+ */
+export function mouthFrame(rings: Ring[], spec: number[]) {
+  const c0 = ringCentre(rings[0])
+  const c1 = ringCentre(rings[1])
+  const theta = Math.atan2(c1[1] - c0[1], c1[0] - c0[0])
+  let halfHeight = 0
+  for (const ring of rings) {
+    let lo = Infinity
+    let hi = -Infinity
+    for (const p of ring) {
+      if (p[1] < lo) lo = p[1]
+      if (p[1] > hi) hi = p[1]
+    }
+    halfHeight = Math.max(halfHeight, (hi - lo) / 2)
+  }
+  const drop = halfHeight + spec[2]
+  return {
+    x: (c0[0] + c1[0]) / 2 - Math.sin(theta) * drop,
+    y: (c0[1] + c1[1]) / 2 + Math.cos(theta) * drop,
+    angle: theta + (spec[3] * Math.PI) / 180,
+  }
+}

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 48 - 0
src/client/components/common/maus/mascot-bodies.ts


+ 22 - 0
src/client/components/common/maus/silhouettes.ts

@@ -0,0 +1,22 @@
+// Adapted from OpenMausBot (3a84701), Apache-2.0. See THIRD_PARTY_NOTICES.md.
+import type { CursorSilhouette } from './cursor-engine'
+type MascotShape = 'circle' | 'oval' | 'square' | 'capsule' | 'triangle' | 'hexagon' | 'cloud' | 'droplet'
+const SOFT_TRIANGLE_PATH = "M86.3935 61.1307 Q114.2705 8 142.1475 61.1307 L192.664 157.4103 Q220.541 210.541 160.541 210.541 H68 Q8 210.541 35.877 157.4103 Z";
+const SOFT_HEXAGON_PATH = "M126.2705 15 Q114.2705 8 102.2705 15 L32 55 Q20 62 20 76 V152 Q20 166 32 173 L102.2705 213 Q114.2705 220 126.2705 213 L196.541 173 Q208.541 166 208.541 152 V76 Q208.541 62 196.541 55 Z";
+const CLOUD_PATH = "M55 193 C6 193 -2 136 28 113 C7 62 61 30 96 54 C122 12 184 30 183 78 C230 83 239 137 208 159 C214 194 174 216 145 196 C116 221 76 216 55 193 Z";
+const DROPLET_PATH = "M104.2705 17 Q114.2705 5 124.2705 17 C148 46 207 97 207 136 C207 185 169 220 114.2705 220 C59 220 21 185 21 136 C21 97 80 46 104.2705 17 Z";
+const pathSilhouette = (name: string, path: string, y: number, scale: number): CursorSilhouette => ({
+  name, fit: "", body: `<path d="${path}" fill="{{GRADIENT}}"/>`, clip: `<path d="${path}"/>`,
+  anchor: { x: 114.2705, y, scale },
+});
+export const MASCOT_SILHOUETTES: Record<MascotShape, CursorSilhouette> = {
+  circle: { name: "circle", fit: "", body: '<circle cx="114.2705" cy="114.2705" r="108" fill="{{GRADIENT}}"/>', clip: '<circle cx="114.2705" cy="114.2705" r="108"/>', anchor: { x: 114.2705, y: 114.2705, scale: 1 } },
+  oval: { name: "oval", fit: "", body: '<ellipse cx="114.2705" cy="114.2705" rx="108" ry="88" fill="{{GRADIENT}}"/>', clip: '<ellipse cx="114.2705" cy="114.2705" rx="108" ry="88"/>', anchor: { x: 114.2705, y: 114.2705, scale: 0.82 } },
+  square: { name: "square", fit: "", body: '<rect x="8" y="8" width="212.541" height="212.541" rx="44" fill="{{GRADIENT}}"/>', clip: '<rect x="8" y="8" width="212.541" height="212.541" rx="44"/>', anchor: { x: 114.2705, y: 114.2705, scale: 1 } },
+  capsule: { name: "capsule", fit: "", body: '<rect x="8" y="48" width="212.541" height="132.541" rx="66.2705" fill="{{GRADIENT}}"/>', clip: '<rect x="8" y="48" width="212.541" height="132.541" rx="66.2705"/>', anchor: { x: 114.2705, y: 114.2705, scale: 0.65 } },
+  triangle: { name: "triangle", fit: "", body: `<path d="${SOFT_TRIANGLE_PATH}" fill="{{GRADIENT}}"/>`, clip: `<path d="${SOFT_TRIANGLE_PATH}"/>`, anchor: { x: 114.2705, y: 125, scale: 0.88 } },
+  hexagon: pathSilhouette("hexagon", SOFT_HEXAGON_PATH, 114.2705, 0.88),
+  cloud: pathSilhouette("cloud", CLOUD_PATH, 131, 0.78),
+  droplet: pathSilhouette("droplet", DROPLET_PATH, 140, 0.78),
+};
+

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

@@ -32,7 +32,7 @@ const props = withDefaults(defineProps<{
   interaction?: UiInteraction | null
   mentionNames?: string[]
   agentAvatars?: Record<string, string>
-  agentStates?: Record<string, 'idle' | 'working' | 'waiting'>
+  agentStates?: Record<string, 'idle' | 'working' | 'waiting' | 'loading' | 'success' | 'failure' | 'notifying'>
   thinking?: boolean
   allowBranch?: boolean
 }>(), {
@@ -88,10 +88,12 @@ let thinkingStartedAt = 0
 
 const formatTime = formatMessageTime
 
-function avatarState(message: UiMessage): 'idle' | 'working' | 'waiting' | 'failure' {
+function avatarState(message: UiMessage): 'idle' | 'working' | 'waiting' | 'loading' | 'success' | 'failure' | 'notifying' {
   if (message.status === 'streaming') return (message.profile && props.agentStates[message.profile]) || 'working'
   if (message.status === 'pending' || message.status === 'unknown-receipt') return 'waiting'
   if (message.status === 'failed') return 'failure'
+  const outcome = message.profile ? props.agentStates[message.profile] : undefined
+  if ((outcome === 'success' || outcome === 'failure') && [...props.messages].reverse().find(m => m.profile === message.profile && m.role === 'assistant')?.id === message.id) return outcome
   return 'idle'
 }
 

+ 8 - 5
src/client/components/workspace/viewModels.ts

@@ -395,15 +395,18 @@ export function workspaceAvatarMembers(memberIds: string[], agents: import('@sha
   const byId = new Map(agents.map(agent => [agent.id, agent]))
   return memberIds.flatMap(id => {
     const agent = byId.get(id)
-    return agent ? [{ name: agent.name, avatar: agent.avatar, state: workspaceAvatarState(conversation, agent.id) }] : []
+    return agent ? [{ name: agent.name, avatar: agent.avatar, state: workspaceAvatarState(conversation, agent.id), activityKey: conversation?.lastSeq }] : []
   })
 }
 
 
-export function workspaceAvatarState(conversation: import('@shared/workspace').WorkspaceConversation | undefined, agentId: string): 'idle' | 'working' | 'waiting' {
+export function workspaceAvatarState(conversation: import('@shared/workspace').WorkspaceConversation | undefined, agentId: string): 'idle' | 'working' | 'waiting' | 'loading' | 'success' | 'failure' | 'notifying' {
   const state = conversation?.activeAgentStates?.[agentId]
-  if (state) return state === 'running' ? 'working' : 'waiting'
-  if (!conversation?.activeRunId || conversation.activeAgentId !== agentId) return 'idle'
+  if (state) return state === 'running' ? 'working' : state === 'queued' ? 'loading' : 'waiting'
+  const signal = conversation?.avatarSignals?.[agentId]
+  if (signal && Date.now() - signal.at < 2000) return signal.state
+  if (conversation?.activeRunStatus === 'queued' && conversation.memberIds[0] === agentId) return 'loading'
+  if (!conversation?.activeRunId || conversation.activeAgentId !== agentId) return conversation?.previewAgentId === agentId && (conversation.unreadCount ?? Math.max(0, conversation.lastSeq - conversation.readSeq)) > 0 ? 'notifying' : 'idle'
   return conversation.activeRunStatus === 'waiting' || conversation.activeRunStatus === 'uncertain' ? 'waiting' : 'working'
 }
 
@@ -414,7 +417,7 @@ export function workspaceConversationItem(c: import('@shared/workspace').Workspa
     avatarMembers: c.kind === 'group' ? workspaceAvatarMembers(c.memberIds, agents, c) : [],
     meta: formatConversationTime(c.lastMessageAt ?? c.createdAt),
     avatarKind: c.kind === 'direct' ? 'agent' : 'team',
-    avatarState: workspaceAvatarState(c, c.memberIds[0] || ''),
+    avatarState: workspaceAvatarState(c, c.memberIds[0] || ''), avatarActivityKey: c.lastSeq,
     unread: c.unreadCount ?? Math.max(0, c.lastSeq - c.readSeq), status: c.activeRunId ? 'working' : undefined,
   }
 }

+ 25 - 4
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 } from '../shared/agentIdentity.js'
+import { decodeAgentMascotAvatar, isAgentImageAvatar, defaultAgentIdentity, encodeAgentAvatar, normalizeAvatar, MAX_AVATAR_DESCRIPTOR_LENGTH } from '../shared/agentIdentity.js'
 import type {
   WorkspaceAgent as Agent,
   WorkspaceConversation as Conversation,
@@ -23,7 +23,7 @@ const name = z
   .refine((v) => !/[\u0000-\u001f@]/.test(v), '名称不能包含 @ 或控制字符')
 const avatar = z
   .string()
-  .max(2_800_000)
+  .max(MAX_AVATAR_DESCRIPTOR_LENGTH)
   .refine(
     (v) => !v || isAgentImageAvatar(v) || !!decodeAgentMascotAvatar(v) || /^builtin:team-animal:(fox|whale|owl|rabbit|bear)$/.test(v),
     '请选择有效的内置头像或 PNG、JPEG、WebP 图片',
@@ -105,6 +105,21 @@ export class WorkspaceStore {
       CREATE INDEX IF NOT EXISTS workspace_turn_status ON workspace_entities(owner, json_extract(data,'$.status'), json_extract(data,'$.planned')) WHERE kind='turn';
       CREATE INDEX IF NOT EXISTS workspace_turn_conversation ON workspace_entities(owner, json_extract(data,'$.conversationId')) WHERE kind='turn';`)
     this.changes.setMaxListeners(0)
+    this.atomic(() => {
+      this.db.exec('CREATE TABLE IF NOT EXISTS workspace_migrations(id TEXT PRIMARY KEY)')
+      if (this.db.prepare('SELECT id FROM workspace_migrations WHERE id=?').get('avatar-v2')) return
+      for (const owner of this.owners()) {
+        for (const agent of this.list<Agent>(owner, 'agent')) {
+          this.put(owner, 'agent', agent.id, { ...agent, avatar: normalizeAvatar(agent.avatar) })
+        }
+        for (const conversation of this.list<Conversation>(owner, 'conversation')) {
+          if (conversation.kind !== 'direct') continue
+          const agent = this.get<Agent>(owner, 'agent', conversation.memberIds[0] || '')
+          this.put(owner, 'conversation', conversation.id, { ...conversation, avatar: agent?.avatar ?? normalizeAvatar(conversation.avatar) })
+        }
+      }
+      this.db.prepare('INSERT INTO workspace_migrations VALUES(?)').run('avatar-v2')
+    })
   }
   get<T>(owner: string, kind: string, id: string): T | undefined {
     const row = this.db
@@ -219,6 +234,7 @@ export class WorkspaceStore {
         id = randomUUID()
       const agent: Agent = {
         ...body,
+        avatar: normalizeAvatar(body.avatar),
         id,
         archived: false,
         revision: 1,
@@ -253,6 +269,7 @@ export class WorkspaceStore {
   }
   updateAgent(owner: string, id: string, input: unknown): Agent {
     const patch = parse(agentPatch, input)
+    if (patch.avatar !== undefined) patch.avatar = normalizeAvatar(patch.avatar)
     return this.atomic(() => {
       const agent = this.require<Agent>(owner, 'agent', id)
       if (
@@ -367,6 +384,7 @@ export class WorkspaceStore {
         c.updatedAt = Date.now()
         c.lastMessageAt = Math.max(c.lastMessageAt ?? 0, message.createdAt)
         c.preview = notificationPlainText(message.content, { maximum: 160, fallback: '' })
+        c.previewAgentId = message.role === 'assistant' ? message.agentId : undefined
       }
       this.put(owner, 'message', message.id, message)
       this.put(owner, 'conversation', c.id, c)
@@ -402,9 +420,12 @@ export class WorkspaceStore {
       c.activeRunId = current?.id
       c.activeAgentId = current?.activeAgentId
       c.activeRunStatus = current?.status
-      const tasks = this.db.prepare("SELECT data FROM workspace_entities WHERE owner=? AND kind='turn' AND json_extract(data,'$.conversationId')=? AND json_extract(data,'$.status') IN ('running','waiting','uncertain')").all(owner, c.id)
+      const tasks = this.db.prepare("SELECT data FROM workspace_entities WHERE owner=? AND kind='turn' AND json_extract(data,'$.conversationId')=? AND json_extract(data,'$.status') IN ('running','waiting','uncertain','queued') AND (json_extract(data,'$.status') != 'queued' OR json_extract(data,'$.planned')=1) ORDER BY CASE json_extract(data,'$.status') WHEN 'queued' THEN 0 WHEN 'running' THEN 1 ELSE 2 END").all(owner, c.id)
         .map(row => JSON.parse(String(row.data)) as { agentId: string; status: string })
-      c.activeAgentStates = Object.fromEntries(tasks.map(t => [t.agentId, t.status as 'running' | 'waiting' | 'uncertain']))
+      c.activeAgentStates = Object.fromEntries(tasks.map(t => [t.agentId, t.status as 'running' | 'waiting' | 'uncertain' | 'queued']))
+      const outcomes = this.db.prepare("SELECT data FROM workspace_entities WHERE owner=? AND kind='turn' AND json_extract(data,'$.conversationId')=? AND json_extract(data,'$.status') IN ('complete','failed') AND json_extract(data,'$.updatedAt')>=? ORDER BY json_extract(data,'$.updatedAt') ASC").all(owner, c.id, Date.now() - 2000)
+        .map(row => JSON.parse(String(row.data)) as {id:string;agentId:string;status:string;updatedAt:number})
+      c.avatarSignals = Object.fromEntries(outcomes.filter(t => Date.now() - t.updatedAt < 2000).map(t => [t.agentId, { id:t.id, state:t.status === 'failed' ? 'failure' as const : 'success' as const, at:t.updatedAt }]))
       c.queuedMessageCount = roots.filter(r => r.status === 'queued').length
       this.put(owner, 'conversation', c.id, c)
       this.event(owner, 'run.changed', run, c.id)

+ 59 - 119
src/shared/agentIdentity.ts

@@ -1,142 +1,82 @@
 export const YAOYAO_AGENT_IDENTITY_NAMESPACE = 'hermes-yaoyao-identity'
-export const YAOYAO_AGENT_IDENTITY_VERSION = 1
-
+export const YAOYAO_AGENT_IDENTITY_VERSION = 2
+export const AVATAR_DESCRIPTOR_PREFIX = 'yaoyao-avatar:v2:'
+export const MAX_AVATAR_IMAGE_LENGTH = 2_800_000
+export const MAX_AVATAR_DESCRIPTOR_LENGTH = MAX_AVATAR_IMAGE_LENGTH + 4096
 export const AGENT_MASCOT_SHAPES = ['circle', 'square', 'triangle', 'ellipse', 'capsule', 'hexagon', 'cloud', 'droplet'] as const
 export type AgentMascotShape = typeof AGENT_MASCOT_SHAPES[number]
 export const AGENT_MASCOT_SHAPE_OPTIONS: readonly AgentMascotShape[] = ['circle', 'ellipse', 'square', 'capsule', 'triangle', 'hexagon', 'cloud', 'droplet']
-
-export const AGENT_MASCOT_EXPRESSIONS = ['friendly', 'focused', 'curious', 'calm'] as const
+export const AGENT_MASCOT_EXPRESSIONS = ['idle', 'happy', 'curious', 'drowsy', 'working', 'thinking', 'listening', 'sleeping', 'suspicious', 'proud'] 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',
-  '#000000', '#a2845e', '#ff2dab', '#8e8e93',
-] as const
-
+export const AGENT_MASCOT_BODIES = ['cursor', 'blob', 'circle', 'squircle', 'capsule', 'drop', 'shield', 'hexagon', 'diamond', 'star'] as const
+export type AgentMascotBody = typeof AGENT_MASCOT_BODIES[number]
+export const AGENT_MASCOT_BODY_LABELS: Record<AgentMascotBody, string> = { cursor: '光标', blob: '软团', circle: '圆球', squircle: '圆角块', capsule: '胶囊体', drop: '液滴', shield: '盾牌', hexagon: '六边体', diamond: '菱形', star: '星星' }
+export const AGENT_MASCOT_EXPRESSION_LABELS: Record<AgentMascotExpression, string> = { idle: '默认', happy: '开心', curious: '好奇', drowsy: '困倦', working: '工作', thinking: '思考', listening: '倾听', sleeping: '睡眠', suspicious: '疑惑', proud: '自豪' }
+export const AGENT_MASCOT_COLORS = ['#000000', '#94643a', '#ff2d45', '#ff6b00', '#ff9900', '#00c875', '#00b9ac', '#1488ff', '#9655f7', '#f52ba5', '#808080'] as const
+export const AGENT_MASCOT_COLOR_LABELS: Record<string, string> = { '#000000':'黑色', '#94643a':'棕色', '#ff2d45':'红色', '#ff6b00':'橙色', '#ff9900':'金黄色', '#00c875':'绿色', '#00b9ac':'青绿色', '#1488ff':'蓝色', '#9655f7':'紫色', '#f52ba5':'粉色', '#808080':'灰色' }
+export const AGENT_IMAGE_CROPS = ['circle', 'rounded', 'square'] as const
+export type AgentImageCrop = typeof AGENT_IMAGE_CROPS[number]
 export type AgentAvatarMode = 'mascot' | 'image'
-
 export interface AgentIdentity {
-  version: 1
+  version: 2
   displayName: string
   avatarMode: AgentAvatarMode
   shape: AgentMascotShape
   color: string
   expression: AgentMascotExpression
+  bodyId?: AgentMascotBody | null
+  imageCrop?: AgentImageCrop
   imageDataURL?: string
 }
-
-export interface AgentIdentityProfileSource {
-  name?: unknown
-  display_name?: unknown
-  displayName?: unknown
-  description?: unknown
-  ui_meta?: unknown
+export interface AgentIdentityProfileSource { name?: unknown; display_name?: unknown; displayName?: unknown; description?: unknown; ui_meta?: unknown }
+const imagePattern = /^data:image\/(png|jpeg|webp);base64,[A-Za-z0-9+/]+={0,2}$/i
+const legacyPattern = /^yaoyao-mascot:v1:(circle|square|triangle|ellipse|capsule|hexagon|cloud|droplet):([0-9a-f]{6}):(friendly|focused|curious|calm)$/i
+function object(v: unknown): Record<string, unknown> { return v && typeof v === 'object' && !Array.isArray(v) ? v as Record<string, unknown> : {} }
+function name(v: unknown): string { return typeof v === 'string' ? v.trim().replace(/\s+/g, ' ').slice(0, 100) : '' }
+export function validAvatarImage(v: unknown): boolean { return typeof v === 'string' && v.length <= MAX_AVATAR_IMAGE_LENGTH && imagePattern.test(v) }
+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' }
 }
-
-const imageDataURLPattern = /^data:image\/(png|jpeg|webp);base64,[A-Za-z0-9+/]+={0,2}$/i
-const mascotAvatarPattern = /^yaoyao-mascot:v1:(circle|square|triangle|ellipse|capsule|hexagon|cloud|droplet):([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 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)
+      || !AGENT_MASCOT_EXPRESSIONS.includes(v.expression as AgentMascotExpression)
+      || (v.bodyId != null && !AGENT_MASCOT_BODIES.includes(v.bodyId as AgentMascotBody))
+      || (v.imageCrop != null && !AGENT_IMAGE_CROPS.includes(v.imageCrop as AgentImageCrop))
+      || (v.imageDataURL != null && !validAvatarImage(v.imageDataURL))
+      || (v.avatarMode === 'image' && !validAvatarImage(v.imageDataURL))) return null
+  return { version: 2, displayName: name(v.displayName), avatarMode: v.avatarMode as AgentAvatarMode, shape: v.shape as AgentMascotShape, color: v.color.toLowerCase(), expression: v.expression as AgentMascotExpression,
+    bodyId: v.bodyId as AgentMascotBody | null ?? null, imageCrop: v.imageCrop as AgentImageCrop ?? 'rounded', ...(v.imageDataURL ? { imageDataURL: v.imageDataURL as string } : {}) }
 }
-
-function cleanName(value: unknown): string {
-  return typeof value === 'string'
-    ? value.trim().replace(/\s+/g, ' ').slice(0, 100)
-    : ''
+export function decodeAgentAvatar(value: string | undefined): AgentIdentity | null {
+  if (!value || value.length > MAX_AVATAR_DESCRIPTOR_LENGTH) return null
+  if (validAvatarImage(value)) return { ...defaultAgentIdentity(''), avatarMode: 'image', imageDataURL: value }
+  if (legacyPattern.test(value)) return defaultAgentIdentity('')
+  if (!value.startsWith(AVATAR_DESCRIPTOR_PREFIX)) return null
+  try { return descriptor(object(JSON.parse(value.slice(AVATAR_DESCRIPTOR_PREFIX.length)))) } catch { return null }
 }
-
-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 encodeAgentAvatar(identity: AgentIdentity): string {
+  const value = descriptor({ ...identity, version: 2, bodyId: identity.bodyId ?? null, imageCrop: identity.imageCrop ?? 'rounded' })
+  if (!value) throw new Error('无效的头像设置')
+  const { displayName: _, ...data } = value
+  return AVATAR_DESCRIPTOR_PREFIX + JSON.stringify(data)
 }
-
-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`, 3)]!,
-    color: AGENT_MASCOT_COLORS[stableIndex(`${key}:color`, 10)]!,
-    expression: AGENT_MASCOT_EXPRESSIONS[stableIndex(`${key}:expression`, AGENT_MASCOT_EXPRESSIONS.length)]!,
-  }
+export function decodeAgentMascotAvatar(value: string | undefined): AgentIdentity | null {
+  const identity = decodeAgentAvatar(value)
+  return identity?.avatarMode === 'mascot' ? identity : null
 }
-
+export function isAgentImageAvatar(value: string | undefined): boolean { return decodeAgentAvatar(value)?.avatarMode === 'image' }
+export function normalizeAvatar(value: string | undefined): string { return encodeAgentAvatar(decodeAgentAvatar(value) ?? defaultAgentIdentity('')) }
 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 } : {}),
-  }
+  const meta = object(source.ui_meta), current = object(meta[YAOYAO_AGENT_IDENTITY_NAMESPACE]), legacy = object(meta['hermes-bots'])
+  const displayName = name(current.display_name) || name(legacy.title) || name(source.display_name) || name(source.displayName) || name(source.description) || name(source.name)
+  const fallback = defaultAgentIdentity(name(source.name), displayName)
+  const image = validAvatarImage(current.image_data_url) ? current.image_data_url as string : validAvatarImage(legacy.avatar) ? legacy.avatar as string : undefined
+  if (current.version !== 2) return { ...fallback, ...(image ? { imageDataURL: image, avatarMode: current.avatar_mode === 'image' || !Object.keys(current).length ? 'image' as const : 'mascot' as const } : {}) }
+  return descriptor({ version: 2, displayName, avatarMode: current.avatar_mode, shape: current.shape, color: current.color, expression: current.expression, bodyId: current.body_id, imageCrop: current.image_crop, imageDataURL: current.image_data_url }) ?? fallback
 }
-
 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))
+  const v = descriptor({ ...identity })
+  if (!v) throw new Error('无效的头像设置')
+  return { version: 2, display_name: name(identity.displayName), avatar_mode: v.avatarMode, shape: v.shape, color: v.color, expression: v.expression, body_id: v.bodyId, image_crop: v.imageCrop, ...(v.imageDataURL ? { image_data_url: v.imageDataURL } : {}) }
 }

+ 3 - 1
src/shared/workspace.ts

@@ -33,10 +33,12 @@ export interface WorkspaceConversation {
   lastSeq: number
   lastMessageAt?: number
   preview: string
+  previewAgentId?: string
   activeRunId?: string
   activeAgentId?: string
   activeRunStatus?: WorkspaceRun['status']
-  activeAgentStates?: Record<string, 'running' | 'waiting' | 'uncertain'>
+  activeAgentStates?: Record<string, 'running' | 'waiting' | 'uncertain' | 'queued'>
+  avatarSignals?: Record<string, { id: string; state: 'success' | 'failure'; at: number }>
   queuedMessageCount?: number
   unreadCount?: number
   createdAt: number

+ 51 - 4
tests/client/agentAvatar.test.ts

@@ -1,5 +1,7 @@
 import { mount } from '@vue/test-utils'
-import { describe, expect, it } from 'vitest'
+import { describe, expect, it, vi } from 'vitest'
+import { nextTick } from 'vue'
+import { defaultAgentIdentity, encodeAgentAvatar } from '@shared/agentIdentity'
 import AgentAvatar from '@/components/common/AgentAvatar.vue'
 import MessageTimeline from '@/components/messages/MessageTimeline.vue'
 
@@ -11,6 +13,7 @@ describe('AgentAvatar', () => {
 
     expect(wrapper.classes()).toContain('agent-avatar--image')
     expect(wrapper.find('img').attributes('src')).toBe(transparentPng)
+    wrapper.unmount()
   })
 
   it('renders a stable animated mascot when no image is configured', () => {
@@ -19,20 +22,24 @@ describe('AgentAvatar', () => {
     expect(wrapper.classes()).not.toContain('agent-avatar--image')
     expect(wrapper.find('img').exists()).toBe(false)
     expect(wrapper.find('svg').exists()).toBe(true)
-    expect(wrapper.findAll('.agent-avatar__eye')).toHaveLength(2)
+    expect(wrapper.find('[data-part=outline] circle').attributes('fill')).toBe('#00c875')
+    expect(wrapper.attributes('data-animated')).toBe('false')
+    expect(wrapper.findAll('[data-part=eye0], [data-part=eye1]')).toHaveLength(2)
+    wrapper.unmount()
   })
 
   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',
+        avatar: encodeAgentAvatar({...defaultAgentIdentity('Scout'),shape:'triangle',color:'#00b9ac',expression:'curious'}),
         state: 'notifying',
       },
     })
 
     expect(wrapper.classes()).toContain('agent-avatar--notifying')
-    expect(wrapper.find('svg path[fill^="url"]').exists()).toBe(true)
+    expect(wrapper.find('[data-part=outline] path[fill="#00b9ac"]').exists()).toBe(true)
+    wrapper.unmount()
   })
 
   it('keeps a group-message image transparent when MessageTimeline adds its fallback class', () => {
@@ -50,3 +57,43 @@ describe('AgentAvatar', () => {
     wrapper.unmount()
   })
 })
+
+it('parks animation frames for idle, hidden, background and reduced-motion avatars', async () => {
+  let visible: ((entries: Array<{isIntersecting:boolean}>) => void) | undefined
+  let motion: (() => void) | undefined
+  let reduced=false, background=false
+  const descriptor=Object.getOwnPropertyDescriptor(document,'visibilityState')
+  const raf=vi.fn(()=>42), cancel=vi.fn()
+  vi.stubGlobal('requestAnimationFrame',raf);vi.stubGlobal('cancelAnimationFrame',cancel)
+  vi.stubGlobal('IntersectionObserver',class {constructor(callback:typeof visible){visible=callback} observe(){} disconnect(){}})
+  vi.stubGlobal('matchMedia',()=>({get matches(){return reduced},addEventListener(_event:string,fn:()=>void){motion=fn},removeEventListener(){}}))
+  Object.defineProperty(document,'visibilityState',{configurable:true,get:()=>background?'hidden':'visible'})
+  const wrapper=mount(AgentAvatar,{props:{name:'quiet',size:32}})
+  try {
+    expect(raf).not.toHaveBeenCalled()
+    await wrapper.setProps({state:'working'})
+    expect(wrapper.attributes('data-animated')).toBe('true')
+    expect(raf).toHaveBeenCalled()
+    visible?.([{isIntersecting:false}]);await nextTick()
+    expect(wrapper.attributes('data-animated')).toBe('false')
+    expect(cancel).toHaveBeenCalled()
+    visible?.([{isIntersecting:true}]);await nextTick()
+    background=true;document.dispatchEvent(new Event('visibilitychange'));await nextTick()
+    expect(wrapper.attributes('data-animated')).toBe('false')
+    background=false;document.dispatchEvent(new Event('visibilitychange'));reduced=true;motion?.();await nextTick()
+    expect(wrapper.attributes('data-animated')).toBe('false')
+  } finally {wrapper.unmount();vi.unstubAllGlobals();if(descriptor)Object.defineProperty(document,'visibilityState',descriptor);else delete (document as any).visibilityState}
+})
+
+it('renders all picture crops from the same bytes and falls back cleanly on decode failure',async()=>{
+  const identity={...defaultAgentIdentity('picture'),avatarMode:'image' as const,imageDataURL:transparentPng}
+  const wrapper=mount(AgentAvatar,{props:{name:'picture',avatar:encodeAgentAvatar({...identity,imageCrop:'circle'})}})
+  expect(wrapper.get('img').element.style.borderRadius).toBe('50%')
+  await wrapper.setProps({avatar:encodeAgentAvatar({...identity,imageCrop:'square'})})
+  expect(wrapper.get('img').element.style.borderRadius).toBe('0')
+  expect(wrapper.get('img').attributes('src')).toBe(transparentPng)
+  await wrapper.get('img').trigger('error');await nextTick()
+  expect(wrapper.find('img').exists()).toBe(false)
+  expect(wrapper.get('[data-part=outline] circle').attributes('fill')).toBe('#00c875')
+  wrapper.unmount()
+})

+ 8 - 9
tests/client/resourceSidebar.test.ts

@@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest'
 import ResourceSidebar from '@/components/app/ResourceSidebar.vue'
 
 describe('ResourceSidebar', () => {
-  it('keeps fallback avatars stable across topics from the same team', () => {
+  it('uses a neutral group placeholder without inventing members', () => {
     const wrapper = mount(ResourceSidebar, {
       props: {
         items: [
@@ -13,13 +13,12 @@ describe('ResourceSidebar', () => {
       },
     })
 
-    const signatures = wrapper.findAll('.team-avatar').map(team => team.findAll('.agent-avatar').map(agent => {
-      const shape = agent.find('.agent-avatar__body').element.firstElementChild?.tagName
-      const color = agent.find('stop[offset="0.5"]').attributes('stop-color')
-      return `${shape}:${color}`
-    }))
-    expect(signatures).toHaveLength(2)
-    expect(signatures[0]).toHaveLength(3)
-    expect(signatures[1]).toEqual(signatures[0])
+    const groups = wrapper.findAll('.team-avatar')
+    expect(groups).toHaveLength(2)
+    for (const group of groups) {
+      expect(group.findAll('.team-avatar__member')).toHaveLength(0)
+      expect(group.find('.app-icon').exists()).toBe(true)
+    }
+    wrapper.unmount()
   })
 })

+ 29 - 0
tests/e2e/avatarReference.spec.ts

@@ -0,0 +1,29 @@
+import {expect,test} from '@playwright/test'
+// Standalone reference comparison; the regular app suite uses its own fixture.
+test('matches all OpenMausBot canonical resting frames',async({page,context},testInfo)=>{
+  const reference=await context.newPage()
+  await reference.goto('http://127.0.0.1:18806')
+  await page.goto('http://127.0.0.1:18807')
+  await expect(reference.locator('[data-testid="shape-circle"] svg path[fill="#ffffff"]').first()).toHaveAttribute('d',/.+/)
+  await expect(page.locator('[data-part=eye0]').first()).toHaveAttribute('d',/.+/)
+  const ids=await page.locator('[data-testid]').evaluateAll(nodes=>nodes.map(n=>n.getAttribute('data-testid')!))
+  expect(ids).toHaveLength(28)
+  for(const id of ids){
+    const expected=await reference.getByTestId(id).locator('svg').evaluate(svg=>{
+      const eyes=[...svg.querySelectorAll('path[fill="#ffffff"]')].slice(0,2)
+      const mouth=svg.querySelector('path[stroke="#ffffff"]')!
+      const body=svg.querySelector('[fill^="url("]')!
+      const attrs=(el:Element)=>Object.fromEntries([...el.attributes].filter(a=>!['fill','data-part'].includes(a.name)).map(a=>[a.name,a.value]))
+      return {viewBox:svg.getAttribute('viewBox'),eyes:eyes.map(e=>({d:e.getAttribute('d'),transform:e.getAttribute('transform')})),mouth:{d:mouth.getAttribute('d'),transform:mouth.getAttribute('transform')},anchor:eyes[0]!.parentElement!.getAttribute('transform'),body:{tag:body.tagName,attrs:attrs(body)},color:svg.querySelector('stop')!.getAttribute('stop-color')!.toLowerCase()}
+    })
+    const actual=await page.getByTestId(id).locator('svg').evaluate(svg=>{
+      const eyes=[svg.querySelector('[data-part=eye0]')!,svg.querySelector('[data-part=eye1]')!],mouth=svg.querySelector('[data-part=mouth]')!,body=svg.querySelector('[data-part=outline]')!.firstElementChild!
+      const attrs=(el:Element)=>Object.fromEntries([...el.attributes].filter(a=>!['fill','data-part'].includes(a.name)).map(a=>[a.name,a.value]))
+      return {viewBox:svg.getAttribute('viewBox'),eyes:eyes.map(e=>({d:e.getAttribute('d'),transform:e.getAttribute('transform')})),mouth:{d:mouth.getAttribute('d'),transform:mouth.getAttribute('transform')},anchor:eyes[0]!.parentElement!.getAttribute('transform'),body:{tag:body.tagName,attrs:attrs(body)},color:body.getAttribute('fill')!.toLowerCase()}
+    })
+    expect(actual,id).toEqual(expected)
+  }
+  await reference.screenshot({path:testInfo.outputPath('openmaus-reference.png'),fullPage:true})
+  await page.screenshot({path:testInfo.outputPath('yaoyao-v2.png'),fullPage:true})
+  await reference.close()
+})

+ 38 - 4
tests/e2e/workspace-chat.spec.ts

@@ -17,7 +17,7 @@ async function createAgent(page: Page, name: string) {
   await dialog
     .getByRole('textbox', { name: '角色提示词与规则', exact: true })
     .fill(`你是${name},每次回复给出验收证据。`)
-  await dialog.getByRole('button', { name: `使用颜色 ${name === '产品经理' ? '#e78531' : '#0ea5c6'}`, exact: true }).click()
+  await dialog.getByRole('button', { name: `使用颜色 ${name === '产品经理' ? '#ff6b00' : '#00b9ac'}`, exact: true }).click()
   await dialog.locator('.mascot-grid--shape button').filter({ hasText: name === '产品经理' ? '圆形' : '小三角' }).first().click()
   await dialog.getByRole('button', { name: '保存', exact: true }).click()
   await expect(page.getByRole('heading', { name, exact: true })).toBeVisible()
@@ -70,6 +70,40 @@ test('created roles and editable teams share a durable chat list without plugin
   await page.addInitScript(() => { Object.defineProperty(crypto, 'randomUUID', { value: undefined, configurable: true }) })
   await page.reload()
   await createAgent(page, '产品经理')
+  await page.getByRole('button', {name:'聊天设置',exact:true}).click()
+  const avatarEditor = page.getByRole('dialog')
+  await expect(avatarEditor.locator('.color-grid button')).toHaveCount(11)
+  await expect(avatarEditor.locator('.mascot-grid--expression button')).toHaveCount(10)
+  await avatarEditor.getByRole('button', {name:'造型:星星',exact:true}).click()
+  await avatarEditor.locator('.mascot-grid--expression button').filter({hasText:'自豪'}).click()
+  await avatarEditor.getByRole('button', {name:'使用颜色 #000000',exact:true}).click()
+  await avatarEditor.getByRole('button', {name:'保存',exact:true}).click()
+  const identities = await (await page.request.get('/api/app/agents')).json()
+  const edited = identities.agents.find((a: {name:string}) => a.name === '产品经理')
+  expect(JSON.parse(edited.avatar.slice('yaoyao-avatar:v2:'.length))).toMatchObject({version:2,bodyId:'star',expression:'proud',color:'#000000'})
+  await page.reload()
+  await page.getByRole('button', {name:'聊天设置',exact:true}).click()
+  await expect(avatarEditor.getByRole('button', {name:'造型:星星',exact:true})).toHaveAttribute('aria-pressed','true')
+  await avatarEditor.locator('input[type=file]').setInputFiles({name:'avatar.png',mimeType:'image/png',buffer:Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=','base64')})
+  await expect(avatarEditor.getByText('图片裁剪',{exact:true})).toBeVisible()
+  let photoBytes=''
+  for(const [label,crop] of [['圆形','circle'],['方形','square'],['圆角方形','rounded']]){
+    await avatarEditor.getByRole('button',{name:label,exact:true}).click()
+    await avatarEditor.getByRole('button',{name:'保存',exact:true}).click()
+    const payload=await (await page.request.get('/api/app/agents')).json()
+    const avatar=JSON.parse(payload.agents.find((a:{name:string})=>a.name==='产品经理').avatar.slice('yaoyao-avatar:v2:'.length))
+    expect(avatar).toMatchObject({avatarMode:'image',imageCrop:crop})
+    if(photoBytes)expect(avatar.imageDataURL).toBe(photoBytes)
+    else photoBytes=avatar.imageDataURL
+    await page.getByRole('button',{name:'聊天设置',exact:true}).click()
+  }
+  await avatarEditor.getByRole('button',{name:'动态吉祥物',exact:true}).click()
+
+  await avatarEditor.locator('.mascot-grid--shape button').filter({hasText:'圆形'}).first().click()
+  await avatarEditor.getByRole('button', {name:'使用颜色 #ff6b00',exact:true}).click()
+  await avatarEditor.locator('.mascot-grid--expression button').filter({hasText:'默认'}).click()
+  await avatarEditor.getByRole('button', {name:'保存',exact:true}).click()
+
   await expect(rail.getByText('聊天', { exact: true })).toHaveCount(0)
   await expect(rail.getByText('聊天列表', { exact: true })).toHaveCount(0)
   await expect(rail.locator('.sidebar-item__row strong').first()).toHaveCSS('font-size', '15px')
@@ -119,9 +153,9 @@ test('created roles and editable teams share a durable chat list without plugin
     const portrait = `.agent-avatar[aria-label="${name} 的头像"]`
     const direct = page.locator('.desktop-sidebar .sidebar-item').filter({ hasText: name }).filter({ hasNot: page.locator('.team-avatar') }).locator(portrait)
     const member = groupRow.locator(portrait)
-    await expect(member.locator('stop[offset="0.5"]')).toHaveAttribute('stop-color', await direct.locator('stop[offset="0.5"]').getAttribute('stop-color') || '')
-    expect(await member.locator('.agent-avatar__body > :first-child').evaluate(el => el.tagName))
-      .toBe(await direct.locator('.agent-avatar__body > :first-child').evaluate(el => el.tagName))
+    await expect(member.locator('[data-part=outline] > :first-child')).toHaveAttribute('fill', await direct.locator('[data-part=outline] > :first-child').getAttribute('fill') || '')
+    expect(await member.locator('[data-part=outline] > :first-child').evaluate(el => el.tagName))
+      .toBe(await direct.locator('[data-part=outline] > :first-child').evaluate(el => el.tagName))
   }
   await expect(page.locator('.desktop-sidebar')).not.toContainText('话题')
   await expect(page.locator('.desktop-sidebar .sidebar-account-switcher__main')).not.toContainText(

+ 44 - 5
tests/server/workspace.test.ts

@@ -5,6 +5,7 @@ import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { randomUUID } from 'node:crypto'
 import { WebSocketServer, type WebSocket } from 'ws'
+import { defaultAgentIdentity, encodeAgentAvatar, decodeAgentAvatar } from '../../src/shared/agentIdentity'
 import { WorkspaceStore } from '../../src/server/workspaceStore'
 import { WorkspaceRuntime, mentionedAgents } from '../../src/server/workspaceRuntime'
 import { WorkspaceNodes } from '../../src/server/workspaceGateway'
@@ -480,14 +481,14 @@ describe('Web-owned workspace', () => {
 
 
 it('pins a direct chat without injecting or clearing optional profile fields', () => {
-  const avatar = 'yaoyao-mascot:v1:triangle:0ea5c6:curious'
+  const avatar = encodeAgentAvatar({...defaultAgentIdentity('reviewer'),shape:'triangle',color:'#00b9ac',expression:'curious'})
   const agent = store.createAgent(owner, { name: '审查员', profile: 'default', avatar })
   const chat = store.list<WorkspaceConversation>(owner, 'conversation')[0]!
   expect(store.updateConversation(owner, chat.id, { pinned: true }).pinned).toBe(true)
   expect(store.updateConversation(owner, chat.id, { pinned: false }).pinned).toBe(false)
   expect(store.require<WorkspaceConversation>(owner, 'conversation', chat.id).avatar).toBe(avatar)
   expect(store.updateAgent(owner, agent.id, { instructions: '请检查边界' }).avatar).toBe(avatar)
-  expect(store.updateAgent(owner, agent.id, { avatar: '' }).avatar).toBe('')
+  expect(decodeAgentAvatar(store.updateAgent(owner, agent.id, { avatar: '' }).avatar)).toMatchObject({shape:'circle',color:'#00c875'})
 })
 
 it('accepts the existing cross-client mascot and team avatar formats', () => {
@@ -529,8 +530,8 @@ it('publishes only the executing member and clears avatar activity when stopped'
 
 it('stores the expanded avatar shapes for cross-client role settings', () => {
   const agent=store.createAgent(owner,{name:'扩展头像验收',profile:'default'})
-  for(const shape of ['ellipse','capsule','hexagon','cloud','droplet']) {
-    const avatar=`yaoyao-mascot:v1:${shape}:ff2dab:curious`
+  for(const shape of ['ellipse','capsule','hexagon','cloud','droplet'] as const) {
+    const avatar=encodeAgentAvatar({...defaultAgentIdentity('bot'),shape,color:'#f52ba5',expression:'curious'})
     expect(store.updateAgent(owner,agent.id,{avatar}).avatar).toBe(avatar)
   }
 })
@@ -571,7 +572,7 @@ it('keeps an unconfirmed turn separate from a later reply and retries history wi
 })
 it('uses one canonical default avatar for the agent and its direct conversation',()=>{
   const a=agent('默认头像'),c=direct(a.id)
-  expect(a.avatar).toBe('')
+  expect(decodeAgentAvatar(a.avatar)).toMatchObject({version:2,color:'#00c875'})
   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)
@@ -866,3 +867,41 @@ it('marks a stopped administrator as interrupted without sending a false complet
   await vi.waitFor(()=>expect(store.require<WorkspaceRun>(owner,'run',root.id).status).toBe('interrupted'))
   expect(notifications).toEqual([])
 })
+
+it('migrates old internal avatars and photos once, synchronizing direct snapshots without changing conversations',()=>{
+  const a=store.createAgent(owner,{name:'旧头像',profile:'default',instructions:'保留规则'})
+  const photo='data:image/png;base64,aGVsbG8='
+  const b=store.createAgent(owner,{name:'照片',profile:'default',avatar:photo})
+  store.put(owner,'agent',a.id,{...a,avatar:'yaoyao-mascot:v1:triangle:ff0000:friendly'})
+  store.put(owner,'agent',b.id,{...b,avatar:photo})
+  store.db.prepare('DELETE FROM workspace_migrations WHERE id=?').run('avatar-v2')
+  const migrated=new WorkspaceStore(home)
+  const next=migrated.require<any>(owner,'agent',a.id)
+  expect(next.instructions).toBe('保留规则')
+  expect(decodeAgentAvatar(next.avatar)).toMatchObject({shape:'circle',color:'#00c875',expression:'idle'})
+  const image=migrated.require<any>(owner,'agent',b.id)
+  expect(decodeAgentAvatar(image.avatar)).toMatchObject({avatarMode:'image',imageDataURL:photo,imageCrop:'rounded'})
+  for(const c of migrated.list<WorkspaceConversation>(owner,'conversation')) expect(c.avatar).toBe(migrated.require<any>(owner,'agent',c.memberIds[0]!).avatar)
+  const customized=encodeAgentAvatar({...defaultAgentIdentity('bot'),bodyId:'star',expression:'proud',color:'#1488ff'})
+  migrated.updateAgent(owner,a.id,{avatar:customized})
+  migrated.close()
+  const reopened=new WorkspaceStore(home)
+  expect(reopened.require<any>(owner,'agent',a.id).avatar).toBe(customized)
+  reopened.close()
+})
+
+// A member can have a queued follow-up while their current task needs approval.
+it('keeps active avatar state ahead of queued work and publishes only recent outcomes', () => {
+  const a=agent('状态优先级'), c=direct(a.id), now=Date.now()
+  const run: WorkspaceRun={id:'avatar-run',conversationId:c.id,messageId:'unused',mentionIds:[],status:'complete',round:1,createdAt:now,updatedAt:now}
+  for (const [id,status] of [['current','waiting'],['next','queued']] as const)
+    store.put(owner,'turn',id,{id,conversationId:c.id,agentId:a.id,status,planned:true,updatedAt:now})
+  store.put(owner,'turn','result',{id:'result',conversationId:c.id,agentId:a.id,status:'failed',updatedAt:now})
+  store.saveRun(owner,run)
+  let summary=store.require<WorkspaceConversation>(owner,'conversation',c.id)
+  expect(summary.activeAgentStates).toEqual({[a.id]:'waiting'})
+  expect(summary.avatarSignals?.[a.id]).toMatchObject({id:'result',state:'failure'})
+  store.put(owner,'turn','result',{id:'result',conversationId:c.id,agentId:a.id,status:'complete',updatedAt:now-3000})
+  store.saveRun(owner,run)
+  expect(store.require<WorkspaceConversation>(owner,'conversation',c.id).avatarSignals).toEqual({})
+})

+ 36 - 56
tests/shared/agentIdentity.test.ts

@@ -1,58 +1,38 @@
-import { describe, expect, it } from 'vitest'
-import {
-  YAOYAO_AGENT_IDENTITY_NAMESPACE,
-  AGENT_MASCOT_SHAPES,
-  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'))
-  })
-})
-
-it('round trips every expanded shape including uploaded-avatar alternatives', () => {
-  expect(AGENT_MASCOT_SHAPES).toHaveLength(8)
-  for (const shape of AGENT_MASCOT_SHAPES) {
-    const encoded = encodeAgentAvatar({...defaultAgentIdentity('designer'), shape, color:'#ff2dab'})
-    expect(decodeAgentMascotAvatar(encoded)).toMatchObject({shape,color:'#ff2dab'})
+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'
+const photo='data:image/png;base64,aGVsbG8='
+describe('avatar v2',()=>{
+ it('resets legacy internal appearance while retaining the configured name',()=>{
+  const identity=agentIdentityFromProfile({name:'bot',ui_meta:{[YAOYAO_AGENT_IDENTITY_NAMESPACE]:{version:1,display_name:'瑶儿',avatar_mode:'mascot',shape:'triangle',color:'#ff0000',expression:'curious'}}})
+  expect(identity).toMatchObject({displayName:'瑶儿',shape:'circle',color:'#00c875',expression:'idle',bodyId:null})
+  expect(decodeAgentAvatar('yaoyao-mascot:v1:triangle:ff0000:curious')).toMatchObject({shape:'circle',color:'#00c875',expression:'idle'})
+ })
+ it('retains raw photo bytes and uses the prior rounded crop',()=>{
+  const decoded=decodeAgentAvatar(normalizeAvatar(photo))
+  expect(decoded).toMatchObject({avatarMode:'image',imageDataURL:photo,imageCrop:'rounded'})
+  expect(agentIdentityFromProfile({name:'bot',ui_meta:{[YAOYAO_AGENT_IDENTITY_NAMESPACE]:{version:1,avatar_mode:'image',image_data_url:photo}}})).toMatchObject({avatarMode:'image',imageDataURL:photo})
+ })
+ it('retains hidden photos when resetting the old mascot',()=>{
+  expect(agentIdentityFromProfile({name:'bot',ui_meta:{[YAOYAO_AGENT_IDENTITY_NAMESPACE]:{version:1,avatar_mode:'mascot',image_data_url:photo}}})).toMatchObject({avatarMode:'mascot',imageDataURL:photo,color:'#00c875'})
+ })
+ it('round trips every supported picker value and metadata without resetting v2',()=>{
+  expect([AGENT_MASCOT_COLORS.length,AGENT_MASCOT_SHAPES.length,AGENT_MASCOT_BODIES.length,AGENT_MASCOT_EXPRESSIONS.length]).toEqual([11,8,10,10])
+  for(const shape of AGENT_MASCOT_SHAPES) for(const bodyId of [null,...AGENT_MASCOT_BODIES]) {
+   const identity={...defaultAgentIdentity('bot'),shape,bodyId,color:'#1488ff' as const,expression:'proud' as const}
+   const encoded=encodeAgentAvatar(identity)
+   expect(normalizeAvatar(encoded)).toBe(encoded)
+   expect(decodeAgentAvatar(encoded)).toMatchObject({shape,bodyId,color:'#1488ff',expression:'proud'})
+   expect(agentIdentityFromProfile({name:'bot',ui_meta:{[YAOYAO_AGENT_IDENTITY_NAMESPACE]:agentIdentityMetadata(identity)}})).toEqual(identity)
+  }
+  for(const imageCrop of AGENT_IMAGE_CROPS) expect(decodeAgentAvatar(encodeAgentAvatar({...defaultAgentIdentity('bot'),avatarMode:'image',imageDataURL:photo,imageCrop}))).toMatchObject({imageDataURL:photo,imageCrop})
+ })
+ 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('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'}]) {
+   expect(decodeAgentAvatar('yaoyao-avatar:v2:'+JSON.stringify({...base,...patch}))).toBeNull()
   }
-  expect(decodeAgentMascotAvatar('yaoyao-mascot:v1:script:ff2dab:friendly')).toBeNull()
+ })
 })

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است