Преглед изворни кода

发布 v0.3.4:恢复聊天样式并完善角色头像与状态

LSamien пре 1 месец
родитељ
комит
9a2f396ec9

+ 1 - 1
README.md

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

+ 11 - 0
docs/releases/v0.3.1.md

@@ -0,0 +1,11 @@
+# v0.3.1
+
+本地修订记录,随 v0.3.4 统一发布。
+
+- 新建 Agent 与群聊复用原有聊天消息、输入框、侧栏和头像选择组件。
+- 修复局域网 HTTP 下新 Agent 发送消息时请求标识生成失败。
+- 修复单聊置顶被头像默认值误判为修改资料,以及更新规则时清空头像的问题。
+- 恢复吉祥物形状、表情、颜色、自定义图片及团队内置头像的选择与跨端保存。
+- 保留新建 Agent、统一聊天列表与固定群成员逻辑;继续由 Web 提供服务。
+
+配套 iOS 1.3(166)。

+ 9 - 0
docs/releases/v0.3.2.md

@@ -0,0 +1,9 @@
+# v0.3.2
+
+本地修订记录,随 v0.3.4 统一发布。
+
+- 群聊头像由真实成员的最新头像动态组合,列表与设置预览保持一致;移除群聊独立头像选图。
+- 聊天列表显示最后消息时间,空聊天显示创建时间;置顶和资料修改不改变最后消息时间。
+- 继续保留固定成员群聊和独立 Agent 对话。
+
+配套 iOS 1.3(167)。

+ 11 - 0
docs/releases/v0.3.3.md

@@ -0,0 +1,11 @@
+# v0.3.3
+
+本地修订记录,随 v0.3.4 统一发布。
+
+- 移除输入框上方额外的协作状态栏。
+- 执行、等待状态直接联动 Agent 头像,群头像只联动当前执行成员。
+- 保留原输入框的发送与停止按钮,不另外增加状态控件。
+
+配套 iOS 1.3(168)。
+
+单个 Agent 在列表中同步显示头像运行状态,会话标题不再显示 Agent 副标题。

+ 21 - 0
docs/releases/v0.3.4.md

@@ -0,0 +1,21 @@
+# v0.3.4
+
+恢复原有聊天界面组件,修复 v0.3.0 新建 Agent 无法发送、单聊置顶失败,以及头像配置与运行状态显示的问题。
+
+- 修复局域网 HTTP 下请求编号生成失败,新建 Agent 可以连续收发并恢复历史。
+- 聊天复用原有消息时间线、输入框与侧栏,移除额外的协作状态栏。
+- 群聊头像由真实成员动态组合;执行与等待状态联动当前成员头像,单个 Agent 在列表中也同步显示状态。
+- 列表显示最后消息时间,空聊天显示创建时间;置顶、改名和修改资料不会改变最后消息时间。
+- 修复单聊置顶被默认头像字段误判为编辑资料,以及修改规则时清空头像的问题。
+- 头像支持圆形、椭圆、圆角方形、胶囊、小三角、六边形、云朵和水滴,提供 14 种颜色、随机外形、重置与图片上传。
+- 扩展头像在 iOS、Web 和群聊组合中同步保存、渲染;会话名称旁不再显示额外的 Agent 字样。
+
+固定群成员、独立角色规则、Web 服务所有权保持不变,不需要 Yaoyao 插件。
+
+配套 iOS 1.3(169)。
+
+## 验证
+
+- 版本清单、类型检查与生产构建通过。
+- 85 个测试文件、460 项单元与服务测试通过。
+- 当前聊天浏览器端到端流程通过:局域网请求编号降级、创建角色、连续协作、固定群成员、单聊置顶、跨页面历史同步、文件来源及归档恢复。

+ 8 - 0
docs/workspace-acceptance.md

@@ -31,3 +31,11 @@ Web:运行上述三个 npm 命令。浏览器用例会自动启动隔离测试
 iOS:使用 `YaoYaoAI` scheme 和可用模拟器,运行 `-only-testing:YaoYaoAITests/WorkspaceChatTests`。需要允许模拟器签名以验证钥匙串登录;禁用签名的安装包不能作为登录验收依据。
 iOS:使用 `YaoYaoAI` scheme 和可用模拟器,运行 `-only-testing:YaoYaoAITests/WorkspaceChatTests`。需要允许模拟器签名以验证钥匙串登录;禁用签名的安装包不能作为登录验收依据。
 
 
 实现和接口说明见 [聊天契约](workspace-chat.md),部署步骤见 [部署说明](agent-install.md)。
 实现和接口说明见 [聊天契约](workspace-chat.md),部署步骤见 [部署说明](agent-install.md)。
+
+
+## v0.3.4 发布验收
+
+- 2026-09-03:完整 Web 测试 85 个文件、460 项通过;发布清单校验、类型检查和生产构建通过。
+- `npx playwright test -c playwright.workspace.config.ts`:当前聊天完整端到端流程通过。
+- 本次包含原有聊天组件恢复、HTTP 发送修复、置顶修复、动态成员头像、头像状态联动、列表消息时间和扩展头像。
+- 旧插件与旧话题协议已退役,不将其浏览器场景作为当前聊天发布条件。

+ 2 - 2
package-lock.json

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

+ 1 - 1
package.json

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

+ 3 - 3
release.json

@@ -1,6 +1,6 @@
 {
 {
   "schemaVersion": 1,
   "schemaVersion": 1,
-  "releaseVersion": "0.3.0",
-  "webVersion": "0.3.0",
-  "gitTag": "v0.3.0"
+  "releaseVersion": "0.3.4",
+  "webVersion": "0.3.4",
+  "gitTag": "v0.3.4"
 }
 }

Разлика између датотеке није приказан због своје велике величине
+ 35 - 6
src/client/components/app/AgentIdentityPanel.vue


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

@@ -2,6 +2,7 @@
 import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
 import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
 import AppIcon from '@/components/common/AppIcon.vue'
 import AppIcon from '@/components/common/AppIcon.vue'
 import TeamAvatar from '@/components/common/TeamAvatar.vue'
 import TeamAvatar from '@/components/common/TeamAvatar.vue'
+import AgentAvatar from '@/components/common/AgentAvatar.vue'
 import type { SidebarItem } from './types'
 import type { SidebarItem } from './types'
 
 
 const SIDEBAR_SEARCH_EVENT = 'hermes-yaoyao:sidebar-search'
 const SIDEBAR_SEARCH_EVENT = 'hermes-yaoyao:sidebar-search'
@@ -149,7 +150,8 @@ defineExpose({ focusSearch })
               <AppIcon name="chevron-down" :size="13" />
               <AppIcon name="chevron-down" :size="13" />
             </button>
             </button>
             <span v-if="!singleLine" class="sidebar-item__icon" :class="{ 'sidebar-item__icon--avatar': !row.item.icon }">
             <span v-if="!singleLine" class="sidebar-item__icon" :class="{ 'sidebar-item__icon--avatar': !row.item.icon }">
-              <TeamAvatar v-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="23" />
+              <AgentAvatar v-if="row.item.avatarKind === 'agent'" :name="row.item.title" :avatar="row.item.avatar" :state="row.item.avatarState" :size="23" />
+              <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="23" />
               <AppIcon v-else-if="row.item.icon" :name="row.item.icon" :size="row.item.topic ? 14 : 15" />
               <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>
               <template v-else>{{ row.item.title.slice(0, 1).toUpperCase() }}</template>
               <span v-if="row.item.status" class="presence" :class="`presence--${row.item.status}`" />
               <span v-if="row.item.status" class="presence" :class="`presence--${row.item.status}`" />

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

@@ -14,9 +14,11 @@ export type SidebarItemBase = {
   expanded?: boolean
   expanded?: boolean
   status?: 'online' | 'working' | 'offline'
   status?: 'online' | 'working' | 'offline'
   icon?: 'chat' | 'groups' | 'file' | 'image' | 'video' | 'audio' | 'link' | 'artifacts' | 'branch' | 'topic' | 'chevron-left' | 'plus' | 'archive'
   icon?: 'chat' | 'groups' | 'file' | 'image' | 'video' | 'audio' | 'link' | 'artifacts' | 'branch' | 'topic' | 'chevron-left' | 'plus' | 'archive'
+  avatarKind?: 'agent' | 'team'
+  avatarState?: 'idle' | 'working' | 'waiting'
   avatar?: string
   avatar?: string
   avatarFallbackKey?: string
   avatarFallbackKey?: string
-  avatarMembers?: Array<{ name: string; avatar?: string }>
+  avatarMembers?: Array<{ name: string; avatar?: string; state?: 'idle' | 'working' | 'waiting' }>
   emptyText?: string
   emptyText?: string
 }
 }
 
 

+ 16 - 4
src/client/components/common/AgentAvatar.vue

@@ -35,7 +35,14 @@ function mix(hex: string, toward: string, amount: number): string {
 
 
 const highlight = computed(() => mix(mascot.value.color, '#ffffff', 0.54))
 const highlight = computed(() => mix(mascot.value.color, '#ffffff', 0.54))
 const shadow = computed(() => mix(mascot.value.color, '#000000', 0.4))
 const shadow = computed(() => mix(mascot.value.color, '#000000', 0.4))
-const faceShift = computed(() => mascot.value.shape === 'triangle' ? -4 : 0)
+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(() => {
 const mouthPath = computed(() => {
   switch (mascot.value.expression) {
   switch (mascot.value.expression) {
     case 'focused': return 'M42 61 Q50 58 58 61'
     case 'focused': return 'M42 61 Q50 58 58 61'
@@ -66,8 +73,13 @@ const mouthPath = computed(() => {
       <g class="agent-avatar__body">
       <g class="agent-avatar__body">
         <circle v-if="mascot.shape === 'circle'" cx="50" cy="50" r="44" :fill="`url(#${gradientId})`" />
         <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})`" />
         <rect v-else-if="mascot.shape === 'square'" x="9" y="9" width="82" height="82" rx="18" :fill="`url(#${gradientId})`" />
-        <path v-else d="M19 18 Q14 13 15 26 L20 76 Q21 88 31 82 L86 57 Q96 52 87 44 L31 17 Q23 13 19 18Z" :fill="`url(#${gradientId})`" />
-        <g class="agent-avatar__face" :transform="`translate(${faceShift} 0)`">
+        <ellipse 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--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" />
           <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" />
           <path class="agent-avatar__mouth" :d="mouthPath" fill="none" stroke="white" stroke-width="4" stroke-linecap="round" />
@@ -78,7 +90,7 @@ const mouthPath = computed(() => {
 </template>
 </template>
 
 
 <style scoped>
 <style scoped>
-.agent-avatar{display:block;position:relative;flex:0 0 auto;overflow:visible;line-height:1}.agent-avatar img,.agent-avatar svg{display:block;width:100%;height:100%;object-fit:cover}.agent-avatar--image img{border-radius:22%}.agent-avatar__body{transform-box:fill-box;transform-origin:center}.agent-avatar__eye{transform-box:fill-box;transform-origin:center;animation:agent-avatar-blink 9s ease-in-out infinite}.agent-avatar__eye--right{animation-delay:32ms}.agent-avatar--working .agent-avatar__body{animation:agent-avatar-working .9s ease-in-out infinite}.agent-avatar--working .agent-avatar__eye{animation-duration:4.1s}.agent-avatar--notifying .agent-avatar__body,.agent-avatar--notifying img{animation:agent-avatar-notifying .7s cubic-bezier(.2,.8,.2,1) 2}.agent-avatar--notifying::after{position:absolute;inset:-3px;border:2px solid v-bind('mascot.color');border-radius:50%;content:"";animation:agent-avatar-ring 1.2s ease-out 2;pointer-events:none}.agent-avatar--waiting .agent-avatar__body{animation:agent-avatar-waiting 1.8s ease-in-out infinite}.agent-avatar--waiting .agent-avatar__eye{animation-duration:3.4s}.agent-avatar--success .agent-avatar__body,.agent-avatar--success img{animation:agent-avatar-success .9s cubic-bezier(.18,.8,.2,1) both}.agent-avatar--failure .agent-avatar__body,.agent-avatar--failure img{animation:agent-avatar-failure .7s ease-in-out both}
+.agent-avatar{display:block;position:relative;flex:0 0 auto;overflow:visible;line-height:1}.agent-avatar img,.agent-avatar svg{display:block;width:100%;height:100%;object-fit:cover}.agent-avatar--image img{border-radius:22%}.agent-avatar__body{transform-box:fill-box;transform-origin:center}.agent-avatar__eye{transform-box:fill-box;transform-origin:center;animation:agent-avatar-blink 9s ease-in-out infinite}.agent-avatar__eye--right{animation-delay:32ms}.agent-avatar--working .agent-avatar__body,.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-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-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-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)}}

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

@@ -6,6 +6,7 @@ import { AGENT_MASCOT_COLORS, AGENT_MASCOT_SHAPES } from '@shared/agentIdentity'
 export type TeamAvatarMember = {
 export type TeamAvatarMember = {
   name: string
   name: string
   avatar?: string
   avatar?: string
+  state?: 'idle' | 'working' | 'waiting'
 }
 }
 
 
 const props = withDefaults(defineProps<{
 const props = withDefaults(defineProps<{
@@ -36,17 +37,20 @@ const clusterMembers = computed(() => {
   const entries = props.members.slice(0, 3).map(member => ({
   const entries = props.members.slice(0, 3).map(member => ({
     name: member.name,
     name: member.name,
     avatar: member.avatar || '',
     avatar: member.avatar || '',
+    state: member.state || 'idle',
   }))
   }))
   const key = props.fallbackKey || props.name || 'team'
   const key = props.fallbackKey || props.name || 'team'
-  while (entries.length < 3) {
+  const fallbackCount = entries.length ? entries.length : 3
+  while (entries.length < fallbackCount) {
     const index = entries.length
     const index = entries.length
     const shape = AGENT_MASCOT_SHAPES[index]!
     const shape = AGENT_MASCOT_SHAPES[index]!
     const color = AGENT_MASCOT_COLORS[
     const color = AGENT_MASCOT_COLORS[
-      stableIndex(`${key}:group:${index}`, AGENT_MASCOT_COLORS.length)
+      stableIndex(`${key}:group:${index}`, 10)
     ]!.slice(1)
     ]!.slice(1)
     entries.push({
     entries.push({
       name: `${props.name || '团队'} ${index + 1}`,
       name: `${props.name || '团队'} ${index + 1}`,
       avatar: `yaoyao-mascot:v1:${shape}:${color}:friendly`,
       avatar: `yaoyao-mascot:v1:${shape}:${color}:friendly`,
+      state: 'idle',
     })
     })
   }
   }
   return entries
   return entries
@@ -69,6 +73,7 @@ const clusterMembers = computed(() => {
         :class="`team-avatar__member--${index + 1}`"
         :class="`team-avatar__member--${index + 1}`"
         :name="member.name"
         :name="member.name"
         :avatar="member.avatar"
         :avatar="member.avatar"
+        :state="member.state"
         :size="Math.round(size * .64)"
         :size="Math.round(size * .64)"
       />
       />
     </span>
     </span>

+ 4 - 2
src/client/components/composer/ComposerShell.vue

@@ -10,6 +10,7 @@ const props = withDefaults(defineProps<{
   placeholder?: string
   placeholder?: string
   disabled?: boolean
   disabled?: boolean
   streaming?: boolean
   streaming?: boolean
+  stopWhileRunning?: boolean
   sending?: boolean
   sending?: boolean
   modelLabel?: string
   modelLabel?: string
   fastMode?: boolean
   fastMode?: boolean
@@ -33,6 +34,7 @@ const props = withDefaults(defineProps<{
   placeholder: '输入消息,Enter 发送,Shift + Enter 换行',
   placeholder: '输入消息,Enter 发送,Shift + Enter 换行',
   disabled: false,
   disabled: false,
   streaming: false,
   streaming: false,
+  stopWhileRunning: false,
   sending: false,
   sending: false,
   modelLabel: '选择模型',
   modelLabel: '选择模型',
   fastMode: false,
   fastMode: false,
@@ -87,7 +89,7 @@ let errorTimer: number | undefined
 const canSubmit = computed(() => !props.disabled && !props.sending && (text.value.trim().length > 0 || attachments.value.length > 0))
 const canSubmit = computed(() => !props.disabled && !props.sending && (text.value.trim().length > 0 || attachments.value.length > 0))
 // A running group still accepts new room messages. Only an ordinary session
 // A running group still accepts new room messages. Only an ordinary session
 // turns its primary send affordance into an interrupt control.
 // turns its primary send affordance into an interrupt control.
-const showStop = computed(() => props.mode === 'chat' && props.streaming && !canSubmit.value)
+const showStop = computed(() => props.streaming && (props.stopWhileRunning || (props.mode === 'chat' && !canSubmit.value)))
 const compactModel = computed(() => props.modelLabel.split('/').filter(Boolean).at(-1) || props.modelLabel)
 const compactModel = computed(() => props.modelLabel.split('/').filter(Boolean).at(-1) || props.modelLabel)
 const contextPercent = computed(() => props.contextLimit > 0 ? Math.min(100, Math.round(props.contextUsed / props.contextLimit * 100)) : 0)
 const contextPercent = computed(() => props.contextLimit > 0 ? Math.min(100, Math.round(props.contextUsed / props.contextLimit * 100)) : 0)
 const hasContext = computed(() => props.contextLimit > 0)
 const hasContext = computed(() => props.contextLimit > 0)
@@ -506,7 +508,7 @@ defineExpose({
           >
           >
             <AppIcon name="bolt" :size="15" />
             <AppIcon name="bolt" :size="15" />
           </button>
           </button>
-          <span v-else class="composer-mention-hint"><b>@</b><span>提及 Agent</span></span>
+          <span v-else-if="mentionOptions.length" class="composer-mention-hint"><b>@</b><span>提及成员</span></span>
         </div>
         </div>
         <div class="composer-actions">
         <div class="composer-actions">
           <button v-if="mode === 'chat' && streaming && canSubmit" class="queue-toggle" :class="{ active: queueMode }" type="button" :title="queueMode ? '消息将排队发送' : '消息将 Steer 当前会话'" @click="emit('queueToggle')">
           <button v-if="mode === 'chat' && streaming && canSubmit" class="queue-toggle" :class="{ active: queueMode }" type="button" :title="queueMode ? '消息将排队发送' : '消息将 Steer 当前会话'" @click="emit('queueToggle')">

+ 7 - 3
src/client/components/messages/MessageTimeline.vue

@@ -32,7 +32,9 @@ const props = withDefaults(defineProps<{
   interaction?: UiInteraction | null
   interaction?: UiInteraction | null
   mentionNames?: string[]
   mentionNames?: string[]
   agentAvatars?: Record<string, string>
   agentAvatars?: Record<string, string>
+  agentStates?: Record<string, 'idle' | 'working' | 'waiting'>
   thinking?: boolean
   thinking?: boolean
+  allowBranch?: boolean
 }>(), {
 }>(), {
   title: '',
   title: '',
   subtitle: '',
   subtitle: '',
@@ -51,7 +53,9 @@ const props = withDefaults(defineProps<{
   interaction: null,
   interaction: null,
   mentionNames: () => [],
   mentionNames: () => [],
   agentAvatars: () => ({}),
   agentAvatars: () => ({}),
+  agentStates: () => ({}),
   thinking: false,
   thinking: false,
+  allowBranch: true,
 })
 })
 
 
 const emit = defineEmits<{
 const emit = defineEmits<{
@@ -85,7 +89,7 @@ let thinkingStartedAt = 0
 const formatTime = formatMessageTime
 const formatTime = formatMessageTime
 
 
 function avatarState(message: UiMessage): 'idle' | 'working' | 'waiting' | 'failure' {
 function avatarState(message: UiMessage): 'idle' | 'working' | 'waiting' | 'failure' {
-  if (message.status === 'streaming') return 'working'
+  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 === 'pending' || message.status === 'unknown-receipt') return 'waiting'
   if (message.status === 'failed') return 'failure'
   if (message.status === 'failed') return 'failure'
   return 'idle'
   return 'idle'
@@ -220,7 +224,7 @@ onBeforeUnmount(() => {
   if (thinkingTimer !== undefined) window.clearInterval(thinkingTimer)
   if (thinkingTimer !== undefined) window.clearInterval(thinkingTimer)
 })
 })
 
 
-defineExpose({ scrollToMessage, scrollToAnchor, scrollToBottom })
+defineExpose({ scrollToMessage, scrollToAnchor, scrollToBottom, isFollowingBottom: () => pinnedToBottom.value })
 </script>
 </script>
 
 
 <template>
 <template>
@@ -364,7 +368,7 @@ defineExpose({ scrollToMessage, scrollToAnchor, scrollToBottom })
                 @click="copyMessage(message)"
                 @click="copyMessage(message)"
               ><AppIcon :name="copiedMessageId === message.id ? 'check' : copyFailedMessageId === message.id ? 'alert' : 'copy'" :size="13" /></button>
               ><AppIcon :name="copiedMessageId === message.id ? 'check' : copyFailedMessageId === message.id ? 'alert' : 'copy'" :size="13" /></button>
               <button type="button" title="引用" aria-label="引用消息" @click="emit('quote', message)"><AppIcon name="quote" :size="13" /></button>
               <button type="button" title="引用" aria-label="引用消息" @click="emit('quote', message)"><AppIcon name="quote" :size="13" /></button>
-              <button v-if="message.role === 'assistant'" type="button" title="从这里分支" aria-label="从这里分支" @click="emit('branch', message)"><AppIcon name="branch" :size="13" /></button>
+              <button v-if="allowBranch && message.role === 'assistant'" type="button" title="从这里分支" aria-label="从这里分支" @click="emit('branch', message)"><AppIcon name="branch" :size="13" /></button>
             </div>
             </div>
             </template>
             </template>
           </div>
           </div>

+ 41 - 104
src/client/components/workspace/ConversationList.vue

@@ -1,114 +1,51 @@
 <script setup lang="ts">
 <script setup lang="ts">
 import { computed, ref } from 'vue'
 import { computed, ref } from 'vue'
-import AgentAvatar from '@/components/common/AgentAvatar.vue'
-import type { WorkspaceConversation } from '@shared/workspace'
-const props = defineProps<{ conversations: WorkspaceConversation[]; selected?: string }>()
-defineEmits<{ select: [id: string] }>()
-const query = ref(''),
-  archived = ref(false)
-const rows = computed(() =>
-  props.conversations.filter(
-    (c) =>
-      c.archived === archived.value &&
-      (!query.value || `${c.name} ${c.preview}`.toLowerCase().includes(query.value.toLowerCase())),
-  ),
-)
+import ResourceSidebar from '@/components/app/ResourceSidebar.vue'
+import type { SidebarItem } from '@/components/app/types'
+import { workspaceAvatarMembers, workspaceAvatarState } from './viewModels'
+import { formatMessageTime } from '@/utils/messageTime'
+import type { WorkspaceAgent, WorkspaceConversation } from '@shared/workspace'
+const props = withDefaults(defineProps<{ conversations: WorkspaceConversation[]; agents?: WorkspaceAgent[]; selected?: string }>(), { agents: () => [] })
+const emit = defineEmits<{ select: [id: string]; pin: [id: string]; archive: [id: string] }>()
+const query = ref(''), archived = ref(false), menuId = ref('')
+const menuPosition = ref({ left: '8px', top: '8px' })
+const menuConversation = computed(() => props.conversations.find(c => c.id === menuId.value))
+const rows = computed<SidebarItem[]>(() => props.conversations.filter(c => c.archived === archived.value &&
+  (!query.value || `${c.name} ${c.preview}`.toLocaleLowerCase().includes(query.value.toLocaleLowerCase())))
+  .map(c => ({ id: c.id, title: c.name, subtitle: c.preview || '开始聊天', pinned: c.pinned,
+    section: c.pinned ? '已置顶' : '聊天', avatar: c.kind === 'group' ? '' : c.avatar,
+    avatarMembers: c.kind === 'group' ? workspaceAvatarMembers(c.memberIds, props.agents, c) : [],
+    meta: formatMessageTime(c.lastMessageAt ?? c.createdAt),
+    avatarKind: c.kind === 'direct' ? 'agent' : 'team',
+    avatarState: workspaceAvatarState(c, c.memberIds[0] || ''),
+    unread: Math.max(0, c.lastSeq - c.readSeq), status: c.activeRunId ? 'working' : undefined })))
+function openMenu(id: string, event: MouseEvent) {
+  menuId.value = id
+  menuPosition.value = { left: `${Math.max(8, Math.min(event.clientX, window.innerWidth - 170))}px`, top: `${Math.max(8, Math.min(event.clientY, window.innerHeight - 100))}px` }
+}
+function action(kind: 'pin' | 'archive') {
+  if (kind === 'pin') emit('pin', menuId.value)
+  else emit('archive', menuId.value)
+  menuId.value = ''
+}
 </script>
 </script>
 <template>
 <template>
   <div class="conversation-list">
   <div class="conversation-list">
-    <input v-model="query" type="search" placeholder="搜索聊天" aria-label="搜索聊天" />
     <label class="archive-filter"><input v-model="archived" type="checkbox" />显示已归档</label>
     <label class="archive-filter"><input v-model="archived" type="checkbox" />显示已归档</label>
-    <button
-      v-for="c in rows"
-      :key="c.id"
-      class="conversation-row"
-      :class="{ selected: c.id === selected }"
-      @click="$emit('select', c.id)"
-    >
-      <AgentAvatar
-        :name="c.name"
-        :avatar="c.avatar"
-        :state="c.activeRunId ? 'working' : 'idle'"
-        :size="38"
-      />
-      <span class="row-text"
-        ><strong>{{ c.pinned ? '⌖ ' : '' }}{{ c.name }}</strong
-        ><small
-          >{{ c.kind === 'group' ? `${c.memberIds.length} 位成员 · ` : ''
-          }}{{ c.preview || '开始聊天' }}</small
-        ></span
-      >
-      <span v-if="c.lastSeq > c.readSeq" class="unread" aria-label="未读消息" />
-    </button>
-    <p v-if="!rows.length" class="empty">
-      {{ archived ? '没有已归档聊天' : '创建一个 Agent,开始你的第一段聊天。' }}
-    </p>
+    <ResourceSidebar :items="rows" :active-id="selected" :search="query" search-placeholder="搜索聊天"
+      empty-title="还没有聊天" empty-description="创建 Agent,或选择成员新建群聊。"
+      @search="query = $event" @select="emit('select', $event)" @more="openMenu" @context-menu="openMenu" />
+    <Teleport to="body">
+      <div v-if="menuConversation" class="conversation-menu-dismiss" @pointerdown.self="menuId = ''" @keydown.esc="menuId = ''">
+        <section class="conversation-actions" :style="menuPosition" role="menu" aria-label="聊天操作">
+          <button role="menuitem" @click="action('pin')">{{ menuConversation.pinned ? '取消置顶' : '置顶聊天' }}</button>
+          <button role="menuitem" @click="action('archive')">{{ menuConversation.archived ? '恢复聊天' : '归档聊天' }}</button>
+        </section>
+      </div>
+    </Teleport>
   </div>
   </div>
 </template>
 </template>
 <style scoped>
 <style scoped>
-.conversation-list {
-  padding: 8px;
-}
-.conversation-list > input {
-  box-sizing: border-box;
-  width: 100%;
-  padding: 10px 12px;
-  border: 1px solid var(--line);
-  border-radius: 10px;
-  background: var(--surface);
-  color: var(--text-primary);
-}
-.archive-filter {
-  display: flex;
-  gap: 6px;
-  align-items: center;
-  font-size: 11px;
-  color: var(--text-muted);
-  padding: 10px 3px;
-}
-.conversation-row {
-  width: 100%;
-  display: flex;
-  align-items: center;
-  gap: 10px;
-  border: 0;
-  border-radius: 12px;
-  background: transparent;
-  color: var(--text-primary);
-  padding: 12px 10px;
-  text-align: left;
-  cursor: pointer;
-}
-.conversation-row:hover,
-.conversation-row.selected {
-  background: var(--surface-soft);
-}
-.row-text {
-  display: grid;
-  gap: 5px;
-  min-width: 0;
-  flex: 1;
-}
-.row-text strong {
-  font-size: 13px;
-}
-.row-text small {
-  font-size: 11px;
-  color: var(--text-muted);
-  overflow: hidden;
-  text-overflow: ellipsis;
-  white-space: nowrap;
-}
-.unread {
-  width: 7px;
-  height: 7px;
-  border-radius: 50%;
-  background: var(--accent, #507f6c);
-}
-.empty {
-  padding: 24px 12px;
-  font-size: 13px;
-  line-height: 1.8;
-  color: var(--text-muted);
-}
+.conversation-list{display:flex;flex:1;flex-direction:column;min-height:0}.archive-filter{display:flex;align-items:center;gap:6px;padding:8px 12px;color:var(--text-muted);font-size:11px}
+.conversation-menu-dismiss{position:fixed;inset:0;z-index:200}.conversation-actions{position:absolute;display:grid;min-width:155px;padding:5px;border:1px solid var(--line);border-radius:10px;background:var(--surface-raised);box-shadow:var(--shadow-float)}.conversation-actions button{padding:9px 12px;border:0;border-radius:7px;background:transparent;color:var(--text-primary);text-align:left;cursor:pointer;font-size:12px}.conversation-actions button:hover{background:var(--surface-hover)}
 </style>
 </style>

+ 42 - 0
src/client/components/workspace/viewModels.ts

@@ -361,3 +361,45 @@ export function artifactToUi(item: ConversationArtifact): UiLibraryItem {
     sourceProfile: item.profile,
     sourceProfile: item.profile,
   }
   }
 }
 }
+
+
+/** Adapt the Web-owned transcript to the existing chat presentation. */
+export function workspaceMessagesToUi(messages: import('@shared/workspace').WorkspaceMessage[]): UiMessage[] {
+  return messages.map(message => ({
+    id: message.id, role: message.role, author: message.agentName,
+    profile: message.agentId, createdAt: message.createdAt,
+    content: message.content.replace(/(!?\[[^\]]*\])\(<?([^)>]+)>?\)/g, (whole, label: string, path: string) => {
+      const file = message.attachments.find(file => file.sourcePath === path)
+      return file ? `${label}(/api/app/files/${file.id}/${label.startsWith('!') ? 'preview' : 'download'})` : whole
+    }),
+    reasoning: message.reasoning,
+    status: message.status === 'complete' || message.status === 'interrupted' ? 'settled'
+      : message.status === 'uncertain' ? 'unknown-receipt'
+      : message.status === 'queued' ? 'pending' : message.status,
+    attachments: message.attachments.map(file => ({
+      id: file.id, name: file.name, size: file.size,
+      kind: file.mimeType.startsWith('image/') ? 'image' : file.mimeType.startsWith('video/') ? 'video' : file.mimeType.startsWith('audio/') ? 'audio' : 'file',
+      url: `/api/app/files/${file.id}/${file.mimeType.startsWith('image/') ? 'preview' : 'download'}`,
+    })),
+    tools: message.tools.map((tool, index) => ({
+      id: String(tool.id || index), name: String(tool.name || tool.tool_name || '工具'),
+      status: String(tool.status).includes('error') ? 'error' : String(tool.status).includes('complete') ? 'success' : 'running',
+      input: tool.arguments ?? tool.input, output: tool.result ?? tool.output,
+    })),
+  }))
+}
+
+
+export function workspaceAvatarMembers(memberIds: string[], agents: import('@shared/workspace').WorkspaceAgent[], conversation?: import('@shared/workspace').WorkspaceConversation) {
+  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) }] : []
+  })
+}
+
+
+export function workspaceAvatarState(conversation: import('@shared/workspace').WorkspaceConversation | undefined, agentId: string): 'idle' | 'working' | 'waiting' {
+  if (!conversation?.activeRunId || conversation.activeAgentId !== agentId) return 'idle'
+  return conversation.activeRunStatus === 'waiting' || conversation.activeRunStatus === 'uncertain' ? 'waiting' : 'working'
+}

+ 114 - 431
src/client/views/ConversationsView.vue

@@ -5,7 +5,19 @@ import WorkspaceShell from '@/components/app/WorkspaceShell.vue'
 import ConversationList from '@/components/workspace/ConversationList.vue'
 import ConversationList from '@/components/workspace/ConversationList.vue'
 import WorkspaceNodesPanel from '@/components/workspace/WorkspaceNodesPanel.vue'
 import WorkspaceNodesPanel from '@/components/workspace/WorkspaceNodesPanel.vue'
 import AgentAvatar from '@/components/common/AgentAvatar.vue'
 import AgentAvatar from '@/components/common/AgentAvatar.vue'
-import MarkdownContent from '@/components/messages/MarkdownContent.vue'
+import { createUuid } from '@/utils/id'
+import MessageTimeline from '@/components/messages/MessageTimeline.vue'
+import ComposerShell from '@/components/composer/ComposerShell.vue'
+import type { ComposerSubmit, ComposerReference } from '@/components/composer/types'
+import PreviewModal from '@/components/library/PreviewModal.vue'
+import ImagePreviewLightbox from '@/components/library/ImagePreviewLightbox.vue'
+import { previewItemFromUrl, mediaItemsFromMessages } from '@/components/library/mediaSequence'
+import type { UiLibraryItem } from '@/components/library/types'
+import type { UiMessage } from '@/components/messages/types'
+import AgentIdentityPanel from '@/components/app/AgentIdentityPanel.vue'
+import TeamAvatar from '@/components/common/TeamAvatar.vue'
+import AppIcon from '@/components/common/AppIcon.vue'
+import { workspaceAvatarMembers, workspaceAvatarState, workspaceMessagesToUi } from '@/components/workspace/viewModels'
 import { useAuthStore } from '@/stores/auth'
 import { useAuthStore } from '@/stores/auth'
 import { useThemeStore } from '@/stores/theme'
 import { useThemeStore } from '@/stores/theme'
 import { apiRequest } from '@/api/client'
 import { apiRequest } from '@/api/client'
@@ -40,6 +52,44 @@ const text = ref(''),
   mentions = ref<string[]>([]),
   mentions = ref<string[]>([]),
   older = ref(true)
   older = ref(true)
 const showNodes = ref(false)
 const showNodes = ref(false)
+const composer = ref<InstanceType<typeof ComposerShell>>()
+const timeline = ref<InstanceType<typeof MessageTimeline>>()
+const quoted = ref<UiMessage | null>(null)
+const preview = ref<UiLibraryItem | null>(null)
+const mediaIndex = ref<number | null>(null)
+const showThinking = ref(true)
+const uiMessages = computed(() => workspaceMessagesToUi(messages.value))
+const media = computed(() => mediaItemsFromMessages(uiMessages.value))
+const lightboxMedia = computed(() => media.value.filter(item => item.kind === 'image' || item.kind === 'video').map(item => ({ url: item.previewUrl || item.downloadUrl || '', name: item.name, type: item.kind as 'image' | 'video' })))
+const reference = computed<ComposerReference | null>(() => quoted.value ? { id: quoted.value.id, content: quoted.value.content, author: quoted.value.author } : null)
+const avatarProfile = computed(() => ({ name: editingId.value || 'Agent', displayName: form.name || 'Agent', agentName: form.name || 'Agent', agentAvatar: form.avatar, isDefault: false, isRunning: true }))
+function openPreview(file: {name: string; url?: string; kind?: string}) {
+  const item = previewItemFromUrl(file.name, file.url || '')
+  const index = lightboxMedia.value.findIndex(m => m.url === (item.previewUrl || item.downloadUrl))
+  if (index >= 0) mediaIndex.value = index
+  else preview.value = item
+}
+let uploadedSources: File[] = [], uploadedReferences: WorkspaceFile[] = []
+async function sendFromComposer(payload: ComposerSubmit) {
+  text.value = quoted.value ? `> ${(quoted.value.author || '我')}: ${quoted.value.content.replace(/\n/g, '\n> ')}\n\n${payload.text}` : payload.text
+  mentions.value = payload.mentionIds
+  if (payload.files.length) {
+    try {
+      busy.value = true
+      if (payload.files.length === uploadedSources.length && payload.files.every((f, i) => f === uploadedSources[i])) files.value = uploadedReferences
+      else {
+        const data = new FormData()
+        for (const file of payload.files) data.append('files', file)
+        files.value = (await apiRequest<{ files: WorkspaceFile[] }>('/api/app/uploads', {method: 'POST', body: data})).files
+        uploadedSources = [...payload.files]
+        uploadedReferences = [...files.value]
+      }
+    } catch (cause) { error.value = cause instanceof Error ? cause.message : '上传失败'; return }
+    finally { busy.value = false }
+  }
+  if (!payload.files.length) files.value = []
+  if (await send()) { composer.value?.clearAfterSend(); quoted.value = null; uploadedSources = []; uploadedReferences = [] }
+}
 const dialog = ref<'agent' | 'group' | 'editAgent' | 'editGroup' | null>(null),
 const dialog = ref<'agent' | 'group' | 'editAgent' | 'editGroup' | null>(null),
   editingId = ref(''),
   editingId = ref(''),
   scroller = ref<HTMLElement>(),
   scroller = ref<HTMLElement>(),
@@ -61,7 +111,8 @@ let cursor = 0,
   disposed = false,
   disposed = false,
   timer: ReturnType<typeof setTimeout> | undefined,
   timer: ReturnType<typeof setTimeout> | undefined,
   generation = 0,
   generation = 0,
-  pendingRequestId: string | undefined
+  pendingRequestId: string | undefined,
+  pendingFingerprint = ''
 const selected = computed(() => (typeof route.params.id === 'string' ? route.params.id : undefined))
 const selected = computed(() => (typeof route.params.id === 'string' ? route.params.id : undefined))
 const members = computed(() => agents.value.filter((a) => active.value?.memberIds.includes(a.id)))
 const members = computed(() => agents.value.filter((a) => active.value?.memberIds.includes(a.id)))
 const isAgentDialog = computed(() => dialog.value === 'agent' || dialog.value === 'editAgent')
 const isAgentDialog = computed(() => dialog.value === 'agent' || dialog.value === 'editAgent')
@@ -78,8 +129,12 @@ async function refresh() {
 }
 }
 async function load(id = selected.value, append = false) {
 async function load(id = selected.value, append = false) {
   if (!id) {
   if (!id) {
+    generation++
     active.value = undefined
     active.value = undefined
     messages.value = []
     messages.value = []
+    run.value = null
+    interactions.value = []
+    context.value = null
     return
     return
   }
   }
   const own = ++generation
   const own = ++generation
@@ -93,9 +148,7 @@ async function load(id = selected.value, append = false) {
       context: Record<string, unknown> | null
       context: Record<string, unknown> | null
     }>(`/api/app/conversations/${id}`)
     }>(`/api/app/conversations/${id}`)
     if (disposed || own !== generation) return
     if (disposed || own !== generation) return
-    const atBottom =
-      !scroller.value ||
-      scroller.value.scrollHeight - scroller.value.scrollTop - scroller.value.clientHeight < 100
+    const atBottom = timeline.value?.isFollowingBottom() ?? true
     active.value = r.conversation
     active.value = r.conversation
     messages.value = append
     messages.value = append
       ? [...new Map([...messages.value, ...r.messages].map((m) => [m.id, m])).values()].sort(
       ? [...new Map([...messages.value, ...r.messages].map((m) => [m.id, m])).values()].sort(
@@ -108,7 +161,7 @@ async function load(id = selected.value, append = false) {
     if (!append) older.value = r.messages.length === 100
     if (!append) older.value = r.messages.length === 100
     if (!append || atBottom) {
     if (!append || atBottom) {
       await nextTick()
       await nextTick()
-      scroller.value?.scrollTo({ top: scroller.value.scrollHeight })
+      if (!append) timeline.value?.scrollToBottom('auto')
       await markRead()
       await markRead()
     }
     }
   } catch (e) {
   } catch (e) {
@@ -149,12 +202,16 @@ async function select(id: string) {
   files.value = []
   files.value = []
   mentions.value = []
   mentions.value = []
   text.value = ''
   text.value = ''
+  quoted.value = null
+  preview.value = null
+  mediaIndex.value = null
   pendingRequestId = undefined
   pendingRequestId = undefined
   await router.push(`/conversations/${id}`)
   await router.push(`/conversations/${id}`)
 }
 }
 async function openDialog(kind: NonNullable<typeof dialog.value>) {
 async function openDialog(kind: NonNullable<typeof dialog.value>) {
   error.value = ''
   error.value = ''
   dialog.value = kind
   dialog.value = kind
+  editingId.value = ''
   Object.assign(form, {
   Object.assign(form, {
     name: '',
     name: '',
     avatar: '',
     avatar: '',
@@ -203,25 +260,11 @@ watch(
     form.autoReplyIds = form.autoReplyIds.filter((id) => ids.includes(id))
     form.autoReplyIds = form.autoReplyIds.filter((id) => ids.includes(id))
   },
   },
 )
 )
-async function avatarChanged(event: Event) {
-  const file = (event.target as HTMLInputElement).files?.[0]
-  if (!file) return
-  if (file.size > 2 * 1024 * 1024) {
-    error.value = '头像请小于 2 MB'
-    return
-  }
-  form.avatar = await new Promise<string>((resolve, reject) => {
-    const r = new FileReader()
-    r.onload = () => resolve(String(r.result))
-    r.onerror = reject
-    r.readAsDataURL(file)
-  })
-}
 async function save() {
 async function save() {
   busy.value = true
   busy.value = true
   error.value = ''
   error.value = ''
   try {
   try {
-    const fields = { name: form.name, avatar: form.avatar, instructions: form.instructions }
+    const fields = { name: form.name, ...(isAgentDialog.value ? { avatar: form.avatar } : {}), instructions: form.instructions }
     if (dialog.value === 'agent') {
     if (dialog.value === 'agent') {
       const [nodeId, profile] = JSON.parse(form.source)
       const [nodeId, profile] = JSON.parse(form.source)
       const result = await apiRequest<{ agent: Agent }>('/api/app/agents', {
       const result = await apiRequest<{ agent: Agent }>('/api/app/agents', {
@@ -286,7 +329,11 @@ async function send() {
   if (!c || busy.value || c.activeRunId || (!text.value.trim() && !files.value.length)) return
   if (!c || busy.value || c.activeRunId || (!text.value.trim() && !files.value.length)) return
   busy.value = true
   busy.value = true
   error.value = ''
   error.value = ''
-  pendingRequestId ??= crypto.randomUUID()
+  const fingerprint = JSON.stringify([c.id, text.value, mentions.value, files.value.map(f => f.id)])
+  if (!pendingRequestId || pendingFingerprint !== fingerprint) {
+    pendingRequestId = createUuid()
+    pendingFingerprint = fingerprint
+  }
   try {
   try {
     await apiRequest(`/api/app/conversations/${c.id}/messages`, {
     await apiRequest(`/api/app/conversations/${c.id}/messages`, {
       method: 'POST',
       method: 'POST',
@@ -301,19 +348,19 @@ async function send() {
     files.value = []
     files.value = []
     mentions.value = []
     mentions.value = []
     pendingRequestId = undefined
     pendingRequestId = undefined
+    pendingFingerprint = ''
     await load(c.id, true)
     await load(c.id, true)
     await refresh()
     await refresh()
+    return true
   } catch (e) {
   } catch (e) {
     error.value = e instanceof Error ? e.message : '发送失败'
     error.value = e instanceof Error ? e.message : '发送失败'
   } finally {
   } finally {
     busy.value = false
     busy.value = false
   }
   }
 }
 }
-watch([text, () => files.value.map((f) => f.id).join(), () => mentions.value.join()], () => {
-  if (!busy.value) pendingRequestId = undefined
-})
-async function action(operation: 'pin' | 'archive') {
-  const c = active.value
+
+async function action(operation: 'pin' | 'archive', id = active.value?.id) {
+  const c = conversations.value.find(c => c.id === id)
   if (!c) return
   if (!c) return
   try {
   try {
     if (operation === 'pin')
     if (operation === 'pin')
@@ -420,158 +467,42 @@ onBeforeUnmount(() => {
       </div></template
       </div></template
     >
     >
     <template #sidebar
     <template #sidebar
-      ><ConversationList :conversations="conversations" :selected="selected" @select="select"
+      ><ConversationList :conversations="conversations" :agents="agents" :selected="selected" @select="select" @pin="action('pin', $event)" @archive="action('archive', $event)"
     /></template>
     /></template>
     <template #mobile-sidebar
     <template #mobile-sidebar
-      ><ConversationList :conversations="conversations" :selected="selected" @select="select"
+      ><ConversationList :conversations="conversations" :agents="agents" :selected="selected" @select="select" @pin="action('pin', $event)" @archive="action('archive', $event)"
     /></template>
     /></template>
     <section class="workspace-chat" aria-label="聊天">
     <section class="workspace-chat" aria-label="聊天">
-      <header v-if="active" class="chat-header">
-        <AgentAvatar :name="active.name" :avatar="active.avatar" :size="38" />
-        <div>
-          <h1>{{ active.name }}</h1>
-          <small>{{
-            active.kind === 'group' ? `${members.length} 位成员 · 成员已固定` : 'Agent 单聊'
-          }}</small>
-        </div>
-        <span class="spacer" /><button @click="action('pin')">
-          {{ active.pinned ? '取消置顶' : '置顶' }}</button
-        ><button @click="openDialog(active.kind === 'direct' ? 'editAgent' : 'editGroup')">
-          设置</button
-        ><button @click="action('archive')">{{ active.archived ? '恢复' : '归档' }}</button>
-      </header>
-      <p v-if="error" class="error" role="alert">
-        {{ error }} <button @click="error = ''" aria-label="关闭错误">×</button>
-      </p>
-      <div
-        v-if="active"
-        ref="scroller"
-        class="messages"
-        @scroll="
-          scroller &&
-          scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight < 40 &&
-          markRead()
-        "
-      >
-        <button v-if="older" class="older" @click="loadOlder">加载更早消息</button>
-        <p v-if="loading && !messages.length">正在加载…</p>
-        <article v-for="m in messages" :key="m.id" class="message" :class="m.role">
-          <small
-            >{{ m.role === 'user' ? '我' : m.agentName || '系统' }}
-            <time>{{
-              new Date(m.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
-            }}</time></small
-          >
-          <details v-if="m.reasoning">
-            <summary>思考过程</summary>
-            <p>{{ m.reasoning }}</p>
-          </details>
-          <MarkdownContent :content="rendered(m)" :streaming="m.status === 'streaming'" />
-          <details v-for="(tool, i) in m.tools" :key="i">
-            <summary>{{ tool.name || tool.tool_name || '工具' }} · {{ tool.status }}</summary>
-            <pre>{{ JSON.stringify(tool, null, 2) }}</pre>
-          </details>
-          <a
-            v-for="file in m.attachments"
-            :key="file.id"
-            class="file"
-            :href="`/api/app/files/${file.id}/download`"
-            target="_blank"
-            rel="noreferrer"
-            ><img
-              v-if="file.mimeType.startsWith('image/')"
-              :src="`/api/app/files/${file.id}/preview`"
-              :alt="file.name"
-            />{{ file.name }}</a
-          ><small v-if="m.status !== 'complete'" class="status">{{
-            {
-              queued: '等待中',
-              streaming: '正在回复…',
-              failed: '执行失败',
-              interrupted: '已停止',
-              uncertain: '执行状态待确认',
-            }[m.status]
-          }}</small>
-        </article>
-        <p v-if="!loading && !messages.length" class="chat-empty">
-          {{ active.kind === 'group' ? '向团队描述你希望完成的事。' : '发一条消息,开始协作。' }}
-        </p>
-      </div>
-      <div v-else class="welcome">
-        <AgentAvatar name="夭夭" :size="72" />
-        <h1>从一个角色开始</h1>
-        <p>创建专属 Agent,为它设置职责和规则。<br />让不同角色在同一个群聊中协作。</p>
-        <button @click="openDialog('agent')">创建 Agent</button>
-      </div>
-      <div v-if="active" class="composer-area">
-        <div v-for="i in interactions" :key="i.id" class="interaction">
-          <strong>{{ i.kind === 'approval' ? '需要审批' : '需要补充信息' }}</strong>
-          <p>{{ i.message }}</p>
-          <template v-if="i.kind === 'approval'"
-            ><button @click="respond(i, 'once')">允许本次</button
-            ><button @click="respond(i, 'deny')">拒绝</button></template
-          ><template v-else
-            ><input v-model="answers[i.id]" aria-label="补充信息" /><button
-              @click="respond(i, answers[i.id] || '')"
-            >
-              提交
-            </button></template
-          >
-        </div>
-        <div v-if="run" class="run-status">
-          {{
-            run.status === 'uncertain'
-              ? '执行状态待确认'
-              : run.status === 'waiting'
-                ? '等待你的确认'
-                : '正在协作'
-          }}<span>{{ run.error }}</span
-          ><button v-if="run.status === 'uncertain'" @click="control('reconcile')">核对状态</button
-          ><button @click="control('stop')">停止</button>
-        </div>
-        <div v-if="active.kind === 'group'" class="mentions">
-          <label v-for="a in members" :key="a.id"
-            ><input v-model="mentions" type="checkbox" :value="a.id" />@{{ a.name }}</label
-          >
-        </div>
-        <div v-if="files.length" class="uploads">
-          <button
-            v-for="f in files"
-            :key="f.id"
-            @click="files = files.filter((x) => x.id !== f.id)"
-          >
-            {{ f.name }} ×
-          </button>
-        </div>
-        <form class="composer" @submit.prevent="send">
-          <textarea
-            v-model="text"
-            :disabled="active.archived"
-            aria-label="消息"
-            :placeholder="active.archived ? '聊天已归档' : '输入消息…'"
-            @keydown.enter.exact.prevent="send"
-          />
-          <div>
-            <input ref="fileInput" hidden type="file" multiple @change="upload" /><button
-              type="button"
-              :disabled="busy || active.archived"
-              @click="fileInput?.click()"
-            >
-              附件</button
-            ><span class="spacer" /><small v-if="context"
-              >上下文 {{ context.context_percent ?? context.percent ?? '—' }}%</small
-            ><button
-              type="submit"
-              :disabled="
-                busy || !!active.activeRunId || active.archived || (!text.trim() && !files.length)
-              "
-            >
-              {{ busy ? '处理中…' : '发送' }}
-            </button>
+      <MessageTimeline ref="timeline" :messages="uiMessages" :title="active?.name || '团队'"
+        :subtitle="active?.kind === 'group' ? `${members.length} 位成员 · 成员已固定` : ''"
+        :loading="loading" :has-older="older && !!active" :connected="!error" :synced="!loading"
+        :show-tools="showThinking" :allow-branch="false" :thinking="!!active?.activeRunId"
+        :agent-avatars="Object.fromEntries(agents.map(a => [a.id, a.avatar]))"
+        :agent-states="Object.fromEntries(members.map(a => [a.id, workspaceAvatarState(active, a.id)]))"
+        :mention-names="members.map(a => a.name)"
+        :empty-title="active ? '开始一段新对话' : '还没有聊天'"
+        :empty-description="active ? '从下方输入框发送消息。' : '创建 Agent,或选择成员新建群聊。'"
+        :interaction="interactions[0] ? { id: interactions[0].id, kind: interactions[0].kind, prompt: interactions[0].message, options: interactions[0].choices } : null"
+        @load-older="loadOlder" @quote="quoted = $event" @preview="openPreview" @preview-file="openPreview"
+        @approve="interactions[0] && respond(interactions[0], $event ? 'once' : 'deny')"
+        @clarify="interactions[0] && respond(interactions[0], $event)">
+        <template #header-actions>
+          <div v-if="active" class="header-actions">
+            <button class="icon-button" :aria-label="active.pinned ? '取消置顶' : '置顶聊天'" :title="active.pinned ? '取消置顶' : '置顶聊天'" @click="action('pin')"><AppIcon :name="active.pinned ? 'pin-off' : 'pin'" /></button>
+            <button class="icon-button" aria-label="聊天设置" title="聊天设置" @click="openDialog(active.kind === 'direct' ? 'editAgent' : 'editGroup')"><AppIcon name="settings" /></button>
+            <button class="icon-button" :aria-label="active.archived ? '恢复聊天' : '归档聊天'" :title="active.archived ? '恢复聊天' : '归档聊天'" @click="action('archive')"><AppIcon name="archive" /></button>
           </div>
           </div>
-        </form>
-      </div>
+        </template>
+      </MessageTimeline>
+      <p v-if="error" class="error" role="alert">{{ error }}<button class="icon-button" @click="error = ''" aria-label="关闭错误"><AppIcon name="close" /></button></p>
+      <ComposerShell v-if="active" :key="active.id" ref="composer" mode="group" :draft-key="`${auth.user?.id}:${active.id}`"
+        :disabled="active.archived || loading || active.id !== selected" :sending="busy" stop-while-running :streaming="!!active.activeRunId"
+        :tool-trace-visible="showThinking" :reference="reference" :context-used="Number(context?.usedTokens || 0)" :context-limit="Number(context?.limitTokens || 0)"
+        :mention-options="active.kind === 'group' ? members.map(a => ({id:a.id,label:a.name,insertText:`@${a.name} `})) : []"
+        @send="sendFromComposer" @stop="control('stop')" @tool-trace-toggle="showThinking = !showThinking" @clear-reference="quoted = null" @error="error = $event" />
     </section>
     </section>
+    <PreviewModal v-if="preview" :item="preview" :items="media" @close="preview = null" />
+    <ImagePreviewLightbox v-model="mediaIndex" :images="lightboxMedia" />
     <div
     <div
       v-if="showNodes"
       v-if="showNodes"
       class="nodes-overlay"
       class="nodes-overlay"
@@ -590,13 +521,13 @@ onBeforeUnmount(() => {
           <button type="button" aria-label="关闭" @click="closeDialog">×</button>
           <button type="button" aria-label="关闭" @click="closeDialog">×</button>
         </header>
         </header>
         <p v-if="error" class="error" role="alert">{{ error }}</p>
         <p v-if="error" class="error" role="alert">{{ error }}</p>
-        <label>名称<input v-model="form.name" required maxlength="100" /></label
-        ><label
-          >头像<input
-            type="file"
-            accept="image/png,image/jpeg,image/webp"
-            @change="avatarChanged" /></label
-        ><AgentAvatar v-if="form.avatar" :name="form.name" :avatar="form.avatar" :size="48" /><label
+        <label>名称<input v-model="form.name" required maxlength="100" /></label>
+        <AgentIdentityPanel v-if="isAgentDialog" :key="`${dialog}:${editingId}`" :profile="avatarProfile" embedded :show-name="false" :show-default-model="false" :show-actions="false" @avatar-change="form.avatar = $event" />
+        <div v-else class="team-avatar-settings">
+          <TeamAvatar :name="form.name" :members="workspaceAvatarMembers(form.memberIds, agents, dialog === 'editGroup' ? active : undefined)" :size="64" />
+          <small>群聊头像由成员头像自动组合,随成员头像更新。</small>
+        </div>
+        <label
           v-if="dialog === 'agent'"
           v-if="dialog === 'agent'"
           >基础 Agent<select v-model="form.source" required>
           >基础 Agent<select v-model="form.source" required>
             <option
             <option
@@ -673,8 +604,8 @@ onBeforeUnmount(() => {
               required /></label
               required /></label
         ></template>
         ></template>
         <footer>
         <footer>
-          <button type="button" @click="closeDialog">取消</button
-          ><button :disabled="busy || (!isAgentDialog && form.memberIds.length < 2)">
+          <button class="quiet-button" type="button" @click="closeDialog">取消</button
+          ><button class="solid-button" :disabled="busy || (!isAgentDialog && form.memberIds.length < 2)">
             {{ busy ? '保存中…' : '保存' }}
             {{ busy ? '保存中…' : '保存' }}
           </button>
           </button>
         </footer>
         </footer>
@@ -683,236 +614,7 @@ onBeforeUnmount(() => {
   </WorkspaceShell>
   </WorkspaceShell>
 </template>
 </template>
 <style scoped>
 <style scoped>
-.nodes-overlay {
-  position: fixed;
-  inset: 0;
-  z-index: 1000;
-  background: #0006;
-  display: grid;
-  place-items: center;
-}
-.nodes-overlay > div {
-  padding: 20px;
-  border-radius: 18px;
-  max-height: 85vh;
-  overflow: auto;
-  width: min(520px, 90vw);
-  background: var(--surface);
-}
-.new-actions {
-  display: flex;
-  gap: 4px;
-  flex-wrap: wrap;
-}
-.workspace-chat {
-  height: 100%;
-  min-height: 0;
-  display: flex;
-  flex-direction: column;
-  color: var(--text-primary);
-  background: var(--surface);
-}
-button {
-  cursor: pointer;
-  color: inherit;
-}
-.chat-header {
-  display: flex;
-  align-items: center;
-  gap: 12px;
-  padding: 17px 24px;
-  border-bottom: 1px solid var(--line);
-}
-h1 {
-  font-size: 18px;
-  margin: 0 0 4px;
-}
-small {
-  color: var(--text-muted);
-}
-.spacer {
-  flex: 1;
-}
-.chat-header button,
-.composer button,
-.run-status button,
-.interaction button,
-.editor button,
-.welcome button {
-  padding: 8px 13px;
-  border: 1px solid var(--line);
-  border-radius: 9px;
-  background: var(--surface-soft);
-}
-button:disabled {
-  opacity: 0.45;
-  cursor: default;
-}
-.messages {
-  flex: 1;
-  min-height: 0;
-  overflow: auto;
-  padding: 24px max(24px, calc((100% - 780px) / 2));
-  scrollbar-gutter: stable;
-}
-.message {
-  margin: 0 0 28px;
-  overflow-wrap: anywhere;
-}
-.message > small {
-  display: block;
-  margin-bottom: 9px;
-  font-size: 11px;
-}
-.message.user {
-  margin-left: 15%;
-  padding: 14px 18px;
-  background: var(--surface-soft);
-  border-radius: 17px;
-}
-.message time {
-  margin-left: 8px;
-  opacity: 0.7;
-}
-.message pre {
-  white-space: pre-wrap;
-  font-size: 11px;
-  max-height: 300px;
-  overflow: auto;
-}
-.message details {
-  margin: 8px 0;
-  font-size: 12px;
-  color: var(--text-secondary);
-}
-.message .file {
-  display: inline-flex;
-  vertical-align: top;
-  flex-direction: column;
-  max-width: 280px;
-  margin: 8px;
-  padding: 10px;
-  border: 1px solid var(--line);
-  border-radius: 12px;
-  color: inherit;
-  font-size: 12px;
-}
-.file img {
-  max-height: 240px;
-  max-width: 100%;
-  object-fit: contain;
-}
-.composer-area {
-  width: min(820px, 100%);
-  box-sizing: border-box;
-  align-self: center;
-  padding: 12px 24px 24px;
-}
-.composer {
-  border: 1px solid var(--line);
-  border-radius: 16px;
-  padding: 12px;
-  background: var(--surface);
-}
-.composer textarea {
-  resize: vertical;
-  width: 100%;
-  box-sizing: border-box;
-  min-height: 75px;
-  max-height: 220px;
-  border: 0;
-  background: transparent;
-  color: inherit;
-  font: inherit;
-  line-height: 1.6;
-  outline: none;
-}
-.composer > div {
-  display: flex;
-  align-items: center;
-  gap: 10px;
-}
-.mentions,
-.uploads {
-  display: flex;
-  flex-wrap: wrap;
-  gap: 10px;
-  margin-bottom: 10px;
-  font-size: 12px;
-}
-.mentions label {
-  display: flex;
-  align-items: center;
-  gap: 3px;
-}
-.uploads button {
-  border: 1px solid var(--line);
-  border-radius: 8px;
-  padding: 7px;
-  background: var(--surface-soft);
-}
-.run-status {
-  display: flex;
-  gap: 10px;
-  align-items: center;
-  flex-wrap: wrap;
-  font-size: 12px;
-  margin-bottom: 10px;
-}
-.run-status span {
-  color: var(--text-muted);
-}
-.error {
-  padding: 12px 20px;
-  margin: 0;
-  background: color-mix(in srgb, var(--danger, #c64c4c) 10%, transparent);
-  color: var(--danger, #c64c4c);
-  font-size: 13px;
-}
-.error button {
-  float: right;
-  border: 0;
-  background: none;
-}
-.welcome {
-  flex: 1;
-  display: flex;
-  flex-direction: column;
-  justify-content: center;
-  align-items: center;
-  text-align: center;
-  padding: 30px;
-  gap: 18px;
-}
-.welcome h1 {
-  font-size: 26px;
-}
-.welcome p {
-  line-height: 1.9;
-  color: var(--text-muted);
-}
-.chat-empty {
-  padding: 60px 20px;
-  text-align: center;
-  color: var(--text-muted);
-}
-.interaction {
-  border: 1px solid var(--line);
-  padding: 16px;
-  border-radius: 12px;
-  margin-bottom: 12px;
-  font-size: 13px;
-}
-.interaction button {
-  margin-right: 8px;
-}
-.older {
-  display: block;
-  margin: 0 auto 25px;
-  border: 0;
-  background: transparent;
-  color: var(--text-muted);
-}
+.workspace-chat{display:flex;min-width:0;min-height:0;flex:1;flex-direction:column}.new-actions{display:grid;gap:2px}.new-actions button{display:flex;align-items:center;min-height:40px;padding:0 11px;border:0;border-radius:9px;background:transparent;color:var(--text-primary);text-align:left;font-size:12px;font-weight:610;cursor:pointer}.new-actions button:hover{background:var(--surface-hover)}.header-actions{display:flex;gap:4px;margin-left:auto}.error{display:flex;align-items:center;justify-content:space-between;margin:0 18px 9px;color:var(--danger);font-size:12px}.team-avatar-settings{display:grid;gap:12px}.team-avatar-options{display:flex;gap:10px}.team-avatar-options button{padding:5px;border:1px solid var(--line);border-radius:10px;background:var(--surface-soft);cursor:pointer}.team-avatar-options button[aria-pressed=true]{border-color:var(--accent)}.nodes-overlay{position:fixed;inset:0;z-index:1000;background:var(--scrim);display:grid;place-items:center}.nodes-overlay>div{padding:20px;max-height:85vh;overflow:auto;width:min(520px,90vw);background:var(--surface-raised);border:1px solid var(--line);border-radius:16px}
 .editor {
 .editor {
   border: 1px solid var(--line);
   border: 1px solid var(--line);
   border-radius: 18px;
   border-radius: 18px;
@@ -969,23 +671,4 @@ button:disabled {
 .status {
 .status {
   margin-top: 8px;
   margin-top: 8px;
 }
 }
-@media (max-width: 700px) {
-  .chat-header {
-    padding: 12px;
-    gap: 8px;
-  }
-  .chat-header button {
-    padding: 6px;
-    font-size: 11px;
-  }
-  .messages {
-    padding: 18px 15px;
-  }
-  .composer-area {
-    padding: 10px 12px 16px;
-  }
-  .message.user {
-    margin-left: 8%;
-  }
-}
 </style>
 </style>

+ 2 - 1
src/server/workspaceRoutes.ts

@@ -91,10 +91,11 @@ export function workspaceRouter(
     ctx.body = {
     ctx.body = {
       conversations: store
       conversations: store
         .list<WorkspaceConversation>(user, 'conversation')
         .list<WorkspaceConversation>(user, 'conversation')
+        .map(c => store.conversationSummary(user, c))
         .sort(
         .sort(
           (a, b) =>
           (a, b) =>
             Number(b.pinned) - Number(a.pinned) ||
             Number(b.pinned) - Number(a.pinned) ||
-            b.updatedAt - a.updatedAt ||
+            (b.lastMessageAt ?? b.createdAt) - (a.lastMessageAt ?? a.createdAt) ||
             a.id.localeCompare(b.id),
             a.id.localeCompare(b.id),
         ),
         ),
       cursor: store.cursor(user),
       cursor: store.cursor(user),

+ 5 - 0
src/server/workspaceRuntime.ts

@@ -173,6 +173,7 @@ export class WorkspaceRuntime {
         conversationId,
         conversationId,
         messageId: message.id,
         messageId: message.id,
         mentionIds: mentions,
         mentionIds: mentions,
+        activeAgentId: queue[0],
         status: 'queued',
         status: 'queued',
         round: 0,
         round: 0,
         queue,
         queue,
@@ -233,6 +234,7 @@ export class WorkspaceRuntime {
           }),
           }),
         }
         }
         run.status = 'running'
         run.status = 'running'
+        run.activeAgentId = agent.id
         this.store.saveRun(owner, run)
         this.store.saveRun(owner, run)
         const message = await this.turn(owner, c, agent, run)
         const message = await this.turn(owner, c, agent, run)
         await this.onMessage(owner, message).catch(() => {})
         await this.onMessage(owner, message).catch(() => {})
@@ -276,6 +278,7 @@ export class WorkspaceRuntime {
       )
       )
         run.hostReturn = true
         run.hostReturn = true
       if (run.queue[0] === agentId) run.queue.shift()
       if (run.queue[0] === agentId) run.queue.shift()
+      run.activeAgentId = run.queue[0]
       run.currentMessageId = undefined
       run.currentMessageId = undefined
       run.error = undefined
       run.error = undefined
       run.status = 'running'
       run.status = 'running'
@@ -292,6 +295,8 @@ export class WorkspaceRuntime {
     const key = `${c.id}:${agent.id}`,
     const key = `${c.id}:${agent.id}`,
       target = this.nodes.target(owner, agent.nodeId)
       target = this.nodes.target(owner, agent.nodeId)
     const gateway = new WorkspaceGateway(target)
     const gateway = new WorkspaceGateway(target)
+    run.activeAgentId = agent.id
+    this.store.saveRun(owner, run)
     let binding = this.store.get<WorkspaceBinding>(owner, 'binding', key)
     let binding = this.store.get<WorkspaceBinding>(owner, 'binding', key)
     let message =
     let message =
       recovering && run.currentMessageId
       recovering && run.currentMessageId

+ 16 - 5
src/server/workspaceStore.ts

@@ -6,6 +6,7 @@ import { EventEmitter } from 'node:events'
 import { z } from 'zod'
 import { z } from 'zod'
 import { HttpError } from './errors.js'
 import { HttpError } from './errors.js'
 import { notificationPlainText } from './notificationText.js'
 import { notificationPlainText } from './notificationText.js'
+import { decodeAgentMascotAvatar, isAgentImageAvatar } from '../shared/agentIdentity.js'
 import type {
 import type {
   WorkspaceAgent as Agent,
   WorkspaceAgent as Agent,
   WorkspaceConversation as Conversation,
   WorkspaceConversation as Conversation,
@@ -23,15 +24,14 @@ const name = z
 const avatar = z
 const avatar = z
   .string()
   .string()
   .max(2_800_000)
   .max(2_800_000)
-  .default('')
   .refine(
   .refine(
-    (v) => !v || /^data:image\/(png|jpeg|webp);base64,[A-Za-z0-9+/=]+$/.test(v),
-    '头像须为 PNG、JPEG 或 WebP 图片',
+    (v) => !v || isAgentImageAvatar(v) || !!decodeAgentMascotAvatar(v) || /^builtin:team-animal:(fox|whale|owl|rabbit|bear)$/.test(v),
+    '请选择有效的内置头像或 PNG、JPEG、WebP 图片',
   )
   )
 export const agentInput = z
 export const agentInput = z
   .object({
   .object({
     name,
     name,
-    avatar,
+    avatar: avatar.default(''),
     instructions: z.string().max(24_000).default(''),
     instructions: z.string().max(24_000).default(''),
     nodeId: z.string().default('local'),
     nodeId: z.string().default('local'),
     profile: z.string().min(1).max(256),
     profile: z.string().min(1).max(256),
@@ -48,7 +48,7 @@ export const agentPatch = z
 export const groupInput = z
 export const groupInput = z
   .object({
   .object({
     name,
     name,
-    avatar,
+    avatar: avatar.default(''),
     memberIds: z.array(z.string().uuid()).min(2).max(8),
     memberIds: z.array(z.string().uuid()).min(2).max(8),
     instructions: z.string().max(24_000).default(''),
     instructions: z.string().max(24_000).default(''),
     administratorId: z.string().uuid(),
     administratorId: z.string().uuid(),
@@ -334,6 +334,7 @@ export class WorkspaceStore {
       if (!message.seq) message.seq = c.lastSeq + 1
       if (!message.seq) message.seq = c.lastSeq + 1
       c.lastSeq = Math.max(c.lastSeq, message.seq)
       c.lastSeq = Math.max(c.lastSeq, message.seq)
       c.updatedAt = Date.now()
       c.updatedAt = Date.now()
+      c.lastMessageAt = Math.max(c.lastMessageAt ?? 0, message.createdAt)
       c.preview = notificationPlainText(message.content, { maximum: 160, fallback: '' })
       c.preview = notificationPlainText(message.content, { maximum: 160, fallback: '' })
       this.put(owner, 'message', message.id, message)
       this.put(owner, 'message', message.id, message)
       this.put(owner, 'conversation', c.id, c)
       this.put(owner, 'conversation', c.id, c)
@@ -341,11 +342,21 @@ export class WorkspaceStore {
       this.event(owner, 'conversation.changed', c, c.id)
       this.event(owner, 'conversation.changed', c, c.id)
     })
     })
   }
   }
+  conversationSummary(owner: string, conversation: Conversation): Conversation {
+    return {
+      ...conversation,
+      lastMessageAt: conversation.lastMessageAt
+        ?? (conversation.lastSeq > 0 ? this.messages(owner, conversation.id, Number.MAX_SAFE_INTEGER, 1).at(-1)?.createdAt : undefined)
+        ?? conversation.createdAt,
+    }
+  }
   saveRun(owner: string, run: Run): void {
   saveRun(owner: string, run: Run): void {
     this.atomic(() => {
     this.atomic(() => {
       const c = this.require<Conversation>(owner, 'conversation', run.conversationId)
       const c = this.require<Conversation>(owner, 'conversation', run.conversationId)
       const terminal = ['complete', 'failed', 'interrupted'].includes(run.status)
       const terminal = ['complete', 'failed', 'interrupted'].includes(run.status)
       c.activeRunId = terminal ? undefined : run.id
       c.activeRunId = terminal ? undefined : run.id
+      c.activeAgentId = terminal ? undefined : run.activeAgentId
+      c.activeRunStatus = terminal ? undefined : run.status
       run.updatedAt = Date.now()
       run.updatedAt = Date.now()
       this.put(owner, 'run', run.id, run)
       this.put(owner, 'run', run.id, run)
       this.put(owner, 'conversation', c.id, c)
       this.put(owner, 'conversation', c.id, c)

+ 6 - 4
src/shared/agentIdentity.ts

@@ -1,8 +1,9 @@
 export const YAOYAO_AGENT_IDENTITY_NAMESPACE = 'hermes-yaoyao-identity'
 export const YAOYAO_AGENT_IDENTITY_NAMESPACE = 'hermes-yaoyao-identity'
 export const YAOYAO_AGENT_IDENTITY_VERSION = 1
 export const YAOYAO_AGENT_IDENTITY_VERSION = 1
 
 
-export const AGENT_MASCOT_SHAPES = ['circle', 'square', 'triangle'] as const
+export const AGENT_MASCOT_SHAPES = ['circle', 'square', 'triangle', 'ellipse', 'capsule', 'hexagon', 'cloud', 'droplet'] as const
 export type AgentMascotShape = typeof AGENT_MASCOT_SHAPES[number]
 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 = ['friendly', 'focused', 'curious', 'calm'] as const
 export type AgentMascotExpression = typeof AGENT_MASCOT_EXPRESSIONS[number]
 export type AgentMascotExpression = typeof AGENT_MASCOT_EXPRESSIONS[number]
@@ -10,6 +11,7 @@ export type AgentMascotExpression = typeof AGENT_MASCOT_EXPRESSIONS[number]
 export const AGENT_MASCOT_COLORS = [
 export const AGENT_MASCOT_COLORS = [
   '#009957', '#377fe6', '#d94b52', '#e78531', '#8057c8',
   '#009957', '#377fe6', '#d94b52', '#e78531', '#8057c8',
   '#0ea5c6', '#d84f8b', '#d8a729', '#01a492', '#e5634e',
   '#0ea5c6', '#d84f8b', '#d8a729', '#01a492', '#e5634e',
+  '#000000', '#a2845e', '#ff2dab', '#8e8e93',
 ] as const
 ] as const
 
 
 export type AgentAvatarMode = 'mascot' | 'image'
 export type AgentAvatarMode = 'mascot' | 'image'
@@ -33,7 +35,7 @@ export interface AgentIdentityProfileSource {
 }
 }
 
 
 const imageDataURLPattern = /^data:image\/(png|jpeg|webp);base64,[A-Za-z0-9+/]+={0,2}$/i
 const imageDataURLPattern = /^data:image\/(png|jpeg|webp);base64,[A-Za-z0-9+/]+={0,2}$/i
-const mascotAvatarPattern = /^yaoyao-mascot:v1:(circle|square|triangle):([0-9a-f]{6}):(friendly|focused|curious|calm)$/i
+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> {
 function object(value: unknown): Record<string, unknown> {
   return value && typeof value === 'object' && !Array.isArray(value)
   return value && typeof value === 'object' && !Array.isArray(value)
@@ -62,8 +64,8 @@ export function defaultAgentIdentity(profileName: string, displayName?: string):
     version: YAOYAO_AGENT_IDENTITY_VERSION,
     version: YAOYAO_AGENT_IDENTITY_VERSION,
     displayName: cleanName(displayName) || key,
     displayName: cleanName(displayName) || key,
     avatarMode: 'mascot',
     avatarMode: 'mascot',
-    shape: AGENT_MASCOT_SHAPES[stableIndex(`${key}:shape`, AGENT_MASCOT_SHAPES.length)]!,
-    color: AGENT_MASCOT_COLORS[stableIndex(`${key}:color`, AGENT_MASCOT_COLORS.length)]!,
+    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)]!,
     expression: AGENT_MASCOT_EXPRESSIONS[stableIndex(`${key}:expression`, AGENT_MASCOT_EXPRESSIONS.length)]!,
   }
   }
 }
 }

+ 4 - 0
src/shared/workspace.ts

@@ -26,8 +26,11 @@ export interface WorkspaceConversation {
   pinned: boolean
   pinned: boolean
   readSeq: number
   readSeq: number
   lastSeq: number
   lastSeq: number
+  lastMessageAt?: number
   preview: string
   preview: string
   activeRunId?: string
   activeRunId?: string
+  activeAgentId?: string
+  activeRunStatus?: WorkspaceRun['status']
   createdAt: number
   createdAt: number
   updatedAt: number
   updatedAt: number
 }
 }
@@ -63,6 +66,7 @@ export interface WorkspaceRun {
   conversationId: string
   conversationId: string
   messageId: string
   messageId: string
   mentionIds: string[]
   mentionIds: string[]
+  activeAgentId?: string
   status: 'queued' | 'running' | 'waiting' | 'complete' | 'failed' | 'interrupted' | 'uncertain'
   status: 'queued' | 'running' | 'waiting' | 'complete' | 'failed' | 'interrupted' | 'uncertain'
   round: number
   round: number
   error?: string
   error?: string

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

@@ -229,7 +229,7 @@ describe('group host controls', () => {
     })
     })
     await wrapper.get('input[placeholder="例如:产品评审"]').setValue('头像团队')
     await wrapper.get('input[placeholder="例如:产品评审"]').setValue('头像团队')
     await wrapper.findAll('.agent-picker > button')[1]!.trigger('click')
     await wrapper.findAll('.agent-picker > button')[1]!.trigger('click')
-    expect(wrapper.findAll('.avatar-picker .team-avatar__member')).toHaveLength(3)
+    expect(wrapper.findAll('.avatar-picker .team-avatar__member')).toHaveLength(choices.length)
     expect(wrapper.get('.avatar-picker').text()).toContain('由成员的动态角色自动组成')
     expect(wrapper.get('.avatar-picker').text()).toContain('由成员的动态角色自动组成')
     expect(wrapper.findAll('.avatar-picker .agent-avatar--image')).toHaveLength(1)
     expect(wrapper.findAll('.avatar-picker .agent-avatar--image')).toHaveLength(1)
     await wrapper.get('.solid-button').trigger('click')
     await wrapper.get('.solid-button').trigger('click')

+ 48 - 0
tests/client/workspacePresentation.test.ts

@@ -0,0 +1,48 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { createUuid } from '@/utils/id'
+import { workspaceMessagesToUi } from '@/components/workspace/viewModels'
+
+afterEach(() => vi.unstubAllGlobals())
+describe('workspace chat uses the established presentation', () => {
+  it('creates valid request ids on LAN HTTP without crypto.randomUUID', () => {
+    vi.stubGlobal('crypto', { getRandomValues: (bytes: Uint8Array) => { for (let i=0;i<bytes.length;i++) bytes[i]=i; return bytes } })
+    expect(createUuid()).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/)
+  })
+  it('preserves role identity, reasoning, files and tools for MessageTimeline', () => {
+    const [message] = workspaceMessagesToUi([{id:'m',conversationId:'c',seq:1,role:'assistant',agentId:'a',agentName:'编辑',content:'[报告](/tmp/report.txt)',reasoning:'检查资料',status:'complete',createdAt:10,attachments:[{id:'f',name:'report.txt',mimeType:'text/plain',size:4,createdAt:10,sender:'agent',sourcePath:'/tmp/report.txt'}],tools:[{id:'t',name:'read_file',status:'tool.complete',result:'ok'}]}])
+    expect(message).toMatchObject({profile:'a',author:'编辑',reasoning:'检查资料',status:'settled',content:'[报告](/api/app/files/f/download)'})
+    expect(message?.tools?.[0]).toMatchObject({name:'read_file',status:'success',output:'ok'})
+    expect(message?.attachments?.[0]?.url).toBe('/api/app/files/f/download')
+  })
+})
+
+import { mount } from '@vue/test-utils'
+import ConversationList from '@/components/workspace/ConversationList.vue'
+import TeamAvatar from '@/components/common/TeamAvatar.vue'
+import type { WorkspaceAgent, WorkspaceConversation } from '../../src/shared/workspace'
+
+it('composes real member avatars and refreshes them without changing group membership', async () => {
+  const agents: WorkspaceAgent[] = ['first','second'].map((id,index) => ({id,name:id,avatar:`yaoyao-mascot:v1:${index ? 'square' : 'circle'}:377fe6:friendly`,instructions:'',nodeId:'local',profile:'default',archived:false,revision:1,createdAt:1,updatedAt:1}))
+  const c: WorkspaceConversation = {id:'g',kind:'group',name:'群聊',avatar:'data:image/png;base64,AA==',memberIds:['second','first'],instructions:'',administratorId:'first',mode:'host',autoReplyIds:[],maxReplyRounds:1,archived:false,pinned:false,readSeq:0,lastSeq:0,preview:'',createdAt:Date.now(),updatedAt:Date.now(),lastMessageAt:Date.now()}
+  const wrapper = mount(ConversationList, {props:{conversations:[c],agents}})
+  expect(wrapper.getComponent(TeamAvatar).props('members')!.map((m: {name:string}) => m.name)).toEqual(['second','first'])
+  expect(wrapper.findAll('.team-avatar__member')).toHaveLength(2)
+  expect(wrapper.find('.team-avatar__image').exists()).toBe(false)
+  expect(wrapper.find('.sidebar-item__row small').text()).toMatch(/\d{2}:\d{2}/)
+  await wrapper.setProps({agents:agents.map(a=>a.id==='first'?{...a,avatar:'yaoyao-mascot:v1:triangle:d94b52:curious'}:a)})
+  expect(wrapper.getComponent(TeamAvatar).props('members')![1]!.avatar).toBe('yaoyao-mascot:v1:triangle:d94b52:curious')
+  await wrapper.setProps({conversations:[{...c,activeRunId:'run',activeAgentId:'second',activeRunStatus:'running'}]})
+  expect(wrapper.findAll('.team-avatar__member.agent-avatar--working')).toHaveLength(1)
+  expect(wrapper.find('.team-avatar__member--1').classes()).toContain('agent-avatar--working')
+  await wrapper.setProps({conversations:[{...c,activeRunId:'run',activeAgentId:'second',activeRunStatus:'waiting'}]})
+  expect(wrapper.findAll('.team-avatar__member.agent-avatar--waiting')).toHaveLength(1)
+  await wrapper.setProps({conversations:[c]})
+  expect(wrapper.findAll('.team-avatar__member.agent-avatar--working')).toHaveLength(0)
+  expect(wrapper.findAll('.team-avatar__member.agent-avatar--waiting')).toHaveLength(0)
+  await wrapper.setProps({conversations:[{...c,kind:'direct',memberIds:['first'],activeRunId:'direct-run',activeAgentId:'first',activeRunStatus:'running'}]})
+  expect(wrapper.find('.sidebar-item__icon .agent-avatar').exists() || wrapper.find('.sidebar-item .agent-avatar').exists()).toBe(true)
+  expect(wrapper.find('.sidebar-item .agent-avatar--working').exists()).toBe(true)
+  await wrapper.setProps({conversations:[{...c,kind:'direct',memberIds:['first'],activeRunId:'direct-run',activeAgentId:'first',activeRunStatus:'waiting'}]})
+  expect(wrapper.find('.sidebar-item .agent-avatar--waiting').exists()).toBe(true)
+  wrapper.unmount()
+})

+ 21 - 18
tests/e2e/workspace-chat.spec.ts

@@ -4,7 +4,7 @@ async function login(page: Page) {
   await page.getByRole('textbox', { name: '账号', exact: true }).fill('fixture')
   await page.getByRole('textbox', { name: '账号', exact: true }).fill('fixture')
   await page.getByRole('textbox', { name: '密码', exact: true }).fill('fixture-pass')
   await page.getByRole('textbox', { name: '密码', exact: true }).fill('fixture-pass')
   await page.getByRole('button', { name: '登录', exact: true }).click()
   await page.getByRole('button', { name: '登录', exact: true }).click()
-  await expect(page.getByRole('heading', { name: '从一个角色开始' })).toBeVisible()
+  await expect(page.getByRole('heading', { name: '还没有聊天' })).toBeVisible()
 }
 }
 async function createAgent(page: Page, name: string) {
 async function createAgent(page: Page, name: string) {
   await page.getByRole('button', { name: '+ 创建 Agent', exact: true }).click()
   await page.getByRole('button', { name: '+ 创建 Agent', exact: true }).click()
@@ -25,14 +25,18 @@ test('created roles and immutable teams share a durable chat list without plugin
     if (r.url().includes('/plugins/yaoyao')) pluginCalls.push(r.url())
     if (r.url().includes('/plugins/yaoyao')) pluginCalls.push(r.url())
   })
   })
   await login(page)
   await login(page)
+  await page.addInitScript(() => { Object.defineProperty(crypto, 'randomUUID', { value: undefined, configurable: true }) })
+  await page.reload()
   await createAgent(page, '产品经理')
   await createAgent(page, '产品经理')
-  await page.getByRole('textbox', { name: '消息', exact: true }).fill('请说明角色职责')
-  await page.getByRole('button', { name: '发送', exact: true }).click()
-  await expect(page.locator('.message.assistant')).toContainText('我是产品经理')
-  await expect(page.locator('.message.assistant a').first()).toHaveAttribute(
+  await page.getByPlaceholder('输入消息,Enter 发送,Shift + Enter 换行').fill('请说明角色职责')
+  await page.getByRole('button', { name: '发送消息', exact: true }).click()
+  await expect(page.locator('.message--assistant')).toContainText('我是产品经理')
+  await expect(page.locator('.message--assistant a').first()).toHaveAttribute(
     'href',
     'href',
     /\/api\/app\/files\/.+\/download/,
     /\/api\/app\/files\/.+\/download/,
   )
   )
+  await page.getByRole('button', {name:'置顶聊天',exact:true}).click()
+  await expect(page.getByRole('button', {name:'取消置顶',exact:true})).toBeVisible()
   const directURL = page.url()
   const directURL = page.url()
   await createAgent(page, '开发工程师')
   await createAgent(page, '开发工程师')
   await expect(page.locator('.message')).toHaveCount(0)
   await expect(page.locator('.message')).toHaveCount(0)
@@ -46,36 +50,35 @@ test('created roles and immutable teams share a durable chat list without plugin
     .selectOption({ label: '产品经理' })
     .selectOption({ label: '产品经理' })
   await dialog.getByRole('button', { name: '保存', exact: true }).click()
   await dialog.getByRole('button', { name: '保存', exact: true }).click()
   await expect(page.getByRole('heading', { name: '产品开发团队', exact: true })).toBeVisible()
   await expect(page.getByRole('heading', { name: '产品开发团队', exact: true })).toBeVisible()
-  await expect(page.locator('.desktop-sidebar .conversation-row')).toHaveCount(3)
+  await expect(page.locator('.desktop-sidebar .sidebar-item')).toHaveCount(3)
   await expect(page.locator('.desktop-sidebar')).not.toContainText('话题')
   await expect(page.locator('.desktop-sidebar')).not.toContainText('话题')
   await expect(page.locator('.desktop-sidebar .sidebar-account-switcher')).not.toContainText(
   await expect(page.locator('.desktop-sidebar .sidebar-account-switcher')).not.toContainText(
     '通用助手',
     '通用助手',
   )
   )
-  await page.getByRole('button', { name: '设置', exact: true }).click()
+  await page.getByRole('button', { name: '聊天设置', exact: true }).click()
   await expect(dialog.getByRole('checkbox', { name: '产品经理', exact: true })).toBeDisabled()
   await expect(dialog.getByRole('checkbox', { name: '产品经理', exact: true })).toBeDisabled()
   await expect(dialog.getByRole('checkbox', { name: '开发工程师', exact: true })).toBeDisabled()
   await expect(dialog.getByRole('checkbox', { name: '开发工程师', exact: true })).toBeDisabled()
   await dialog.getByRole('button', { name: '取消', exact: true }).click()
   await dialog.getByRole('button', { name: '取消', exact: true }).click()
-  await page.getByRole('checkbox', { name: '@开发工程师', exact: true }).check()
-  await page.getByRole('textbox', { name: '消息', exact: true }).fill('给出实现结果')
-  await page.getByRole('button', { name: '发送', exact: true }).click()
-  await expect(page.locator('.message.assistant')).toHaveCount(2)
+  await page.getByPlaceholder('输入消息,Enter 发送,Shift + Enter 换行').fill('@开发工程师 给出实现结果')
+  await page.getByRole('button', { name: '发送消息', exact: true }).click()
+  await expect(page.locator('.message--assistant')).toHaveCount(2)
   await expect(page.locator('.run-status')).toHaveCount(0)
   await expect(page.locator('.run-status')).toHaveCount(0)
   const second = await context.newPage()
   const second = await context.newPage()
   await second.goto(page.url())
   await second.goto(page.url())
-  await expect(second.locator('.message.assistant')).toHaveCount(2)
+  await expect(second.locator('.message--assistant')).toHaveCount(2)
   await page.screenshot({ path: testInfo.outputPath('mixed-chats.png'), fullPage: true })
   await page.screenshot({ path: testInfo.outputPath('mixed-chats.png'), fullPage: true })
   const groupURL=page.url()
   const groupURL=page.url()
   await page.getByRole('button',{name:'文件库',exact:true}).click()
   await page.getByRole('button',{name:'文件库',exact:true}).click()
   await page.getByRole('button',{name:'查看来源',exact:true}).first().click()
   await page.getByRole('button',{name:'查看来源',exact:true}).first().click()
   await expect(page).toHaveURL(groupURL)
   await expect(page).toHaveURL(groupURL)
-  await page.getByRole('button', { name: '归档', exact: true }).click()
-  await expect(page.locator('.desktop-sidebar .conversation-row')).toHaveCount(2)
+  await page.getByRole('button', { name: '归档聊天', exact: true }).click()
+  await expect(page.locator('.desktop-sidebar .sidebar-item')).toHaveCount(2)
   await page.getByRole('checkbox', { name: '显示已归档', exact: true }).first().check()
   await page.getByRole('checkbox', { name: '显示已归档', exact: true }).first().check()
-  await expect(page.locator('.desktop-sidebar .conversation-row')).toHaveCount(1)
-  await page.getByRole('button', { name: '恢复', exact: true }).click()
+  await expect(page.locator('.desktop-sidebar .sidebar-item')).toHaveCount(1)
+  await page.getByRole('button', { name: '恢复聊天', exact: true }).click()
   await page.goto(directURL)
   await page.goto(directURL)
-  await expect(page.locator('.message.assistant')).toHaveCount(1)
-  await expect(page.locator('.message.assistant')).toContainText('我是产品经理')
+  await expect(page.locator('.message--assistant')).toHaveCount(1)
+  await expect(page.locator('.message--assistant')).toContainText('我是产品经理')
   await page.getByRole('button', { name: '原生对话', exact: true }).click()
   await page.getByRole('button', { name: '原生对话', exact: true }).click()
   await expect(page).toHaveURL(/\/chat/)
   await expect(page).toHaveURL(/\/chat/)
   expect(pluginCalls).toEqual([])
   expect(pluginCalls).toEqual([])

+ 57 - 0
tests/server/workspace.test.ts

@@ -414,3 +414,60 @@ describe('Web-owned workspace', () => {
     expect(store.require<WorkspaceConversation>(owner, 'conversation', c.id).name).toBe('乙')
     expect(store.require<WorkspaceConversation>(owner, 'conversation', c.id).name).toBe('乙')
   })
   })
 })
 })
+
+
+it('pins a direct chat without injecting or clearing optional profile fields', () => {
+  const avatar = 'yaoyao-mascot:v1:triangle:0ea5c6: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('')
+})
+
+it('accepts the existing cross-client mascot and team avatar formats', () => {
+  const a = store.createAgent(owner, { name: '设计', profile: 'default', avatar: 'yaoyao-mascot:v1:square:377fe6:friendly' })
+  const b = store.createAgent(owner, { name: '开发', profile: 'default' })
+  const group = store.createGroup(owner, { name: '产品团队', memberIds: [a.id, b.id], administratorId: a.id, avatar: 'builtin:team-animal:fox' })
+  expect(group.avatar).toBe('builtin:team-animal:fox')
+  expect(() => store.updateAgent(owner, a.id, { avatar: 'javascript:alert(1)' })).toThrow()
+})
+
+it('keeps the last message time independent of pinning and metadata edits', () => {
+  const agent=store.createAgent(owner,{name:'时间验收',profile:'default'})
+  const c=store.list<WorkspaceConversation>(owner,'conversation')[0]!
+  const message: WorkspaceMessage={id:randomUUID(),conversationId:c.id,seq:0,role:'user',content:'历史消息',reasoning:'',status:'complete',attachments:[],tools:[],createdAt:Date.now()-600_000}
+  store.saveMessage(owner,message)
+  const pinned=store.updateConversation(owner,c.id,{pinned:true})
+  expect(store.conversationSummary(owner,pinned).lastMessageAt).toBe(message.createdAt)
+  store.updateAgent(owner,agent.id,{name:'改名之后'})
+  expect(store.conversationSummary(owner,store.require(owner,'conversation',c.id)).lastMessageAt).toBe(message.createdAt)
+  // Existing records without the summary field still use their transcript time.
+  const previous={...pinned}; delete previous.lastMessageAt
+  expect(store.conversationSummary(owner,previous).lastMessageAt).toBe(message.createdAt)
+})
+
+
+it('publishes only the executing member and clears avatar activity when stopped', async () => {
+  const a=store.createAgent(owner,{name:'管理员头像',profile:'default'})
+  const b=store.createAgent(owner,{name:'执行者头像',profile:'default'})
+  const c=store.createGroup(owner,{name:'头像状态',memberIds:[a.id,b.id],administratorId:a.id})
+  reply=(socket,p)=>socket.send(JSON.stringify({method:'event',params:{type:'approval.request',session_id:p.session_id,payload:{request_id:'approval-avatar',message:'需要确认'}}}))
+  const run=runtime.send(owner,c.id,{requestId:randomUUID(),content:'处理任务',mentionIds:[b.id]})
+  await vi.waitFor(()=>expect(store.require<WorkspaceRun>(owner,'run',run.id).status).toBe('waiting'))
+  expect(store.require<WorkspaceConversation>(owner,'conversation',c.id)).toMatchObject({activeAgentId:b.id,activeRunStatus:'waiting'})
+  await runtime.stop(owner,run.id)
+  const stopped=store.require<WorkspaceConversation>(owner,'conversation',c.id)
+  expect(stopped.activeAgentId).toBeUndefined()
+  expect(stopped.activeRunStatus).toBeUndefined()
+})
+
+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`
+    expect(store.updateAgent(owner,agent.id,{avatar}).avatar).toBe(avatar)
+  }
+})

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

@@ -1,6 +1,7 @@
 import { describe, expect, it } from 'vitest'
 import { describe, expect, it } from 'vitest'
 import {
 import {
   YAOYAO_AGENT_IDENTITY_NAMESPACE,
   YAOYAO_AGENT_IDENTITY_NAMESPACE,
+  AGENT_MASCOT_SHAPES,
   agentIdentityFromProfile,
   agentIdentityFromProfile,
   decodeAgentMascotAvatar,
   decodeAgentMascotAvatar,
   defaultAgentIdentity,
   defaultAgentIdentity,
@@ -46,3 +47,12 @@ describe('Yaoyao agent identity', () => {
     expect(defaultAgentIdentity('designer')).not.toEqual(defaultAgentIdentity('reviewer'))
     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'})
+  }
+  expect(decodeAgentMascotAvatar('yaoyao-mascot:v1:script:ff2dab:friendly')).toBeNull()
+})

Неке датотеке нису приказане због велике количине промена