Pārlūkot izejas kodu

聊天页头同步显示头像和名称

Samien 1 mēnesi atpakaļ
vecāks
revīzija
100efe9978

+ 22 - 3
src/client/components/workspace/WorkspaceMessageTimeline.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 AgentAvatar from '@/components/common/AgentAvatar.vue'
 import AgentAvatar from '@/components/common/AgentAvatar.vue'
+import TeamAvatar from '@/components/common/TeamAvatar.vue'
 import EmptyState from '@/components/common/EmptyState.vue'
 import EmptyState from '@/components/common/EmptyState.vue'
 import TypingIndicator from '@/components/common/TypingIndicator.vue'
 import TypingIndicator from '@/components/common/TypingIndicator.vue'
 import InteractionCard from '../messages/InteractionCard.vue'
 import InteractionCard from '../messages/InteractionCard.vue'
@@ -16,11 +17,20 @@ import { copyTextToClipboard } from '@/utils/clipboard'
 import { displayContentForMessage } from '@/utils/messageDisplay'
 import { displayContentForMessage } from '@/utils/messageDisplay'
 import { formatMessageTime } from '@/utils/messageTime'
 import { formatMessageTime } from '@/utils/messageTime'
 
 
+type AvatarState = 'idle' | 'working' | 'waiting' | 'loading' | 'success' | 'failure' | 'notifying'
+type HeaderAvatarMember = { name: string; avatar?: string; state?: AvatarState; activityKey?: number }
+
 const props = withDefaults(defineProps<{
 const props = withDefaults(defineProps<{
   messages: UiMessage[]
   messages: UiMessage[]
   identity: string
   identity: string
   title?: string
   title?: string
   subtitle?: string
   subtitle?: string
+  headerAvatarName?: string
+  headerAvatar?: string
+  headerAvatarKind?: 'agent' | 'team'
+  headerAvatarMembers?: HeaderAvatarMember[]
+  headerAvatarState?: AvatarState
+  headerAvatarActivityKey?: string | number
   emptyTitle?: string
   emptyTitle?: string
   emptyDescription?: string
   emptyDescription?: string
   emptyLogo?: boolean
   emptyLogo?: boolean
@@ -39,13 +49,18 @@ const props = withDefaults(defineProps<{
   approvalAgentName?: string
   approvalAgentName?: string
   mentionNames?: string[]
   mentionNames?: string[]
   agentAvatars?: Record<string, string>
   agentAvatars?: Record<string, string>
-  agentStates?: Record<string, 'idle' | 'working' | 'waiting' | 'loading' | 'success' | 'failure' | 'notifying'>
+  agentStates?: Record<string, AvatarState>
   thinking?: boolean
   thinking?: boolean
   allowBranch?: boolean
   allowBranch?: boolean
   readOnly?: boolean
   readOnly?: boolean
 }>(), {
 }>(), {
   title: '',
   title: '',
   subtitle: '',
   subtitle: '',
+  headerAvatarName: '',
+  headerAvatar: '',
+  headerAvatarKind: 'agent',
+  headerAvatarMembers: () => [],
+  headerAvatarState: 'idle',
   emptyTitle: '开始一段新对话',
   emptyTitle: '开始一段新对话',
   emptyDescription: '从下方输入框发送消息,或选择左侧的历史会话。',
   emptyDescription: '从下方输入框发送消息,或选择左侧的历史会话。',
   emptyLogo: false,
   emptyLogo: false,
@@ -298,7 +313,11 @@ defineExpose({ scrollToMessage, scrollToAnchor, scrollToBottom, isFollowingBotto
   <section class="timeline-frame" :class="{ 'timeline-frame--transparent': transparentHeader }">
   <section class="timeline-frame" :class="{ 'timeline-frame--transparent': transparentHeader }">
     <header v-if="title || transparentHeader" class="timeline-header" :class="{ 'timeline-header--transparent': transparentHeader }">
     <header v-if="title || transparentHeader" class="timeline-header" :class="{ 'timeline-header--transparent': transparentHeader }">
       <slot name="header-leading" />
       <slot name="header-leading" />
-      <div v-if="!transparentHeader"><h2>{{ title }}</h2><p v-if="subtitle">{{ subtitle }}</p></div>
+      <div v-if="!transparentHeader" class="timeline-identity">
+        <TeamAvatar v-if="headerAvatarName && headerAvatarKind === 'team'" :name="headerAvatarName" :avatar="headerAvatar" :members="headerAvatarMembers" :size="34" />
+        <AgentAvatar v-else-if="headerAvatarName" class="timeline-header__avatar" :name="headerAvatarName" :avatar="headerAvatar" :state="headerAvatarState" :activity-key="headerAvatarActivityKey" :size="34" />
+        <div class="timeline-heading"><h2>{{ title }}</h2><p v-if="subtitle">{{ subtitle }}</p></div>
+      </div>
       <div v-if="!transparentHeader" class="timeline-state" :title="connected ? (synced ? '实时连接与历史均已同步' : '实时连接已就绪,历史同步中') : '实时连接未就绪'">
       <div v-if="!transparentHeader" class="timeline-state" :title="connected ? (synced ? '实时连接与历史均已同步' : '实时连接已就绪,历史同步中') : '实时连接未就绪'">
         <span class="status-dot" :class="connected ? (synced ? 'status-dot--online' : 'status-dot--working') : ''" />
         <span class="status-dot" :class="connected ? (synced ? 'status-dot--online' : 'status-dot--working') : ''" />
         {{ connected ? (synced ? '已同步' : '同步中') : '离线' }}
         {{ connected ? (synced ? '已同步' : '同步中') : '离线' }}
@@ -497,7 +516,7 @@ defineExpose({ scrollToMessage, scrollToAnchor, scrollToBottom, isFollowingBotto
 .timeline-header { display: flex; z-index: 6; min-height: 62px; align-items: center; gap: 13px; padding: 10px 18px; border-bottom: 1px solid var(--line); background: color-mix(in srgb, var(--canvas) 92%, transparent); backdrop-filter: blur(16px); }
 .timeline-header { display: flex; z-index: 6; min-height: 62px; align-items: center; gap: 13px; padding: 10px 18px; border-bottom: 1px solid var(--line); background: color-mix(in srgb, var(--canvas) 92%, transparent); backdrop-filter: blur(16px); }
 .timeline-header--transparent { position: absolute; top: 0; right: 0; left: 0; min-height: 0; justify-content: flex-end; padding: 11px 14px; border: 0; background: transparent; backdrop-filter: none; pointer-events: none; }
 .timeline-header--transparent { position: absolute; top: 0; right: 0; left: 0; min-height: 0; justify-content: flex-end; padding: 11px 14px; border: 0; background: transparent; backdrop-filter: none; pointer-events: none; }
 .timeline-header--transparent > * { pointer-events: auto; }
 .timeline-header--transparent > * { pointer-events: auto; }
-.timeline-header > div:first-child { min-width: 0; flex: 1; }.timeline-header h2 { overflow: hidden; margin: 0; font-size: 14px; font-weight: 630; letter-spacing: -.02em; text-overflow: ellipsis; white-space: nowrap; }.timeline-header p { overflow: hidden; margin: 3px 0 0; color: var(--text-muted); font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }
+.timeline-identity { display: flex; min-width: 0; flex: 1; align-items: center; gap: 10px; }.timeline-heading { min-width: 0; }.timeline-header h2 { overflow: hidden; margin: 0; font-size: 14px; font-weight: 630; letter-spacing: -.02em; text-overflow: ellipsis; white-space: nowrap; }.timeline-header p { overflow: hidden; margin: 3px 0 0; color: var(--text-muted); font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }
 .timeline-state { display: flex; align-items: center; gap: 6px; color: var(--text-muted); font-size: 9px; }
 .timeline-state { display: flex; align-items: center; gap: 6px; color: var(--text-muted); font-size: 9px; }
 .timeline { min-height: 0; flex: 1; overflow-y: auto; overflow-anchor: none; overscroll-behavior: contain; background: var(--conversation-canvas); }
 .timeline { min-height: 0; flex: 1; overflow-y: auto; overflow-anchor: none; overscroll-behavior: contain; background: var(--conversation-canvas); }
 .message-stack { width: min(780px, 100%); min-height: 100%; margin: 0 auto; padding: 24px 24px 36px; }
 .message-stack { width: min(780px, 100%); min-height: 100%; margin: 0 auto; padding: 24px 24px 36px; }

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

@@ -249,6 +249,11 @@ async function leaveComputer(){
 onBeforeRouteUpdate(leaveComputer);onBeforeRouteLeave(leaveComputer)
 onBeforeRouteUpdate(leaveComputer);onBeforeRouteLeave(leaveComputer)
 const computerCandidates=computed(()=>agents.value.filter(agent=>!agent.archived&&(active.value?.memberIds.includes(agent.id)||(!!agent.temporaryGoalId&&agent.temporaryGoalId===activeTask.value?.goal?.id))))
 const computerCandidates=computed(()=>agents.value.filter(agent=>!agent.archived&&(active.value?.memberIds.includes(agent.id)||(!!agent.temporaryGoalId&&agent.temporaryGoalId===activeTask.value?.goal?.id))))
 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 activeHeaderAgent = computed(() => active.value?.kind === 'direct' ? agents.value.find(agent => agent.id === active.value?.memberIds[0]) : undefined)
+const activeHeaderTitle = computed(() => activeHeaderAgent.value?.name || active.value?.name || 'Bot 模式')
+const activeHeaderAvatarName = computed(() => active.value ? activeHeaderAgent.value?.name || active.value.name : '')
+const activeHeaderAvatar = computed(() => activeHeaderAgent.value?.avatar || active.value?.avatar || '')
+const activeHeaderAvatarMembers = computed(() => active.value?.kind === 'group' ? workspaceAvatarMembers(active.value.memberIds, agents.value, active.value) : [])
 const isAgentDialog = computed(() => dialog.value === 'agent' || dialog.value === 'editAgent')
 const isAgentDialog = computed(() => dialog.value === 'agent' || dialog.value === 'editAgent')
 const body = (v: unknown) => v as JsonValue
 const body = (v: unknown) => v as JsonValue
 async function saveProfileIdentity(input: ProfileIdentityInput) {
 async function saveProfileIdentity(input: ProfileIdentityInput) {
@@ -963,8 +968,10 @@ onBeforeUnmount(() => {
     /></template>
     /></template>
     <div v-show="!twoDesktops" class="conversation-with-computer">
     <div v-show="!twoDesktops" class="conversation-with-computer">
     <section class="workspace-chat" aria-label="聊天" @click.capture="openTaskLink">
     <section class="workspace-chat" aria-label="聊天" @click.capture="openTaskLink">
-      <MessageTimeline ref="timeline" :identity="`${selected}:${activeTask?.id ?? ''}`" :loading-older="loadingOlder" :messages="uiMessages" :title="active?.name || 'Bot 模式'"
+      <MessageTimeline ref="timeline" :identity="`${selected}:${activeTask?.id ?? ''}`" :loading-older="loadingOlder" :messages="uiMessages" :title="activeHeaderTitle"
         :subtitle="active?.kind === 'group' ? `${members.length} 位成员` : ''"
         :subtitle="active?.kind === 'group' ? `${members.length} 位成员` : ''"
+        :header-avatar-name="activeHeaderAvatarName" :header-avatar="activeHeaderAvatar" :header-avatar-kind="active?.kind === 'group' ? 'team' : 'agent'"
+        :header-avatar-members="activeHeaderAvatarMembers" :header-avatar-state="activeHeaderAgent ? workspaceAvatarState(active, activeHeaderAgent.id) : 'idle'" :header-avatar-activity-key="active?.lastSeq"
         :loading="loading" :has-older="older && !!active" :connected="!error" :synced="!loading"
         :loading="loading" :has-older="older && !!active" :connected="!error" :synced="!loading"
         :show-tools="showThinking" :allow-branch="false" :thinking="thinking"
         :show-tools="showThinking" :allow-branch="false" :thinking="thinking"
         :agent-avatars="Object.fromEntries(agents.map(a => [a.id, a.avatar]))"
         :agent-avatars="Object.fromEntries(agents.map(a => [a.id, a.avatar]))"

+ 8 - 1
tests/client/conversationSettings.test.ts

@@ -3,6 +3,7 @@ import { flushPromises, shallowMount, type VueWrapper } from '@vue/test-utils'
 import { shallowRef } from 'vue'
 import { shallowRef } from 'vue'
 import { createMemoryHistory, createRouter, matchedRouteKey } from 'vue-router'
 import { createMemoryHistory, createRouter, matchedRouteKey } from 'vue-router'
 import ConversationsView from '@/views/ConversationsView.vue'
 import ConversationsView from '@/views/ConversationsView.vue'
+import WorkspaceMessageTimeline from '@/components/workspace/WorkspaceMessageTimeline.vue'
 import { apiRequest } from '@/api/client'
 import { apiRequest } from '@/api/client'
 import type { WorkspaceConversation } from '@shared/workspace'
 import type { WorkspaceConversation } from '@shared/workspace'
 
 
@@ -19,7 +20,7 @@ it.each(['direct', 'group'] as const)('opens and saves the menu target %s while
   const base: WorkspaceConversation = { id: 'current', kind: 'direct', name: '当前聊天', avatar: '', memberIds: ['current-bot'], instructions: '', administratorId: 'current-bot', mode: 'host', autoReplyIds: [], maxReplyRounds: 3, archived: false, pinned: false, readSeq: 0, lastSeq: 0, preview: '', createdAt: 1, updatedAt: 1 }
   const base: WorkspaceConversation = { id: 'current', kind: 'direct', name: '当前聊天', avatar: '', memberIds: ['current-bot'], instructions: '', administratorId: 'current-bot', mode: 'host', autoReplyIds: [], maxReplyRounds: 3, archived: false, pinned: false, readSeq: 0, lastSeq: 0, preview: '', createdAt: 1, updatedAt: 1 }
   const target: WorkspaceConversation = { ...base, id: 'target', kind, name: '目标聊天', memberIds: ['target-bot', ...(kind === 'group' ? ['current-bot'] : [])], administratorId: 'target-bot', instructions: '目标规则', activeAgentStates: kind === 'group' ? { 'target-bot': 'running' } : {} }
   const target: WorkspaceConversation = { ...base, id: 'target', kind, name: '目标聊天', memberIds: ['target-bot', ...(kind === 'group' ? ['current-bot'] : [])], administratorId: 'target-bot', instructions: '目标规则', activeAgentStates: kind === 'group' ? { 'target-bot': 'running' } : {} }
   const conversations = [base, target]
   const conversations = [base, target]
-  const agents = ['current-bot', 'target-bot'].map(id => ({ id, name: id === 'target-bot' ? '目标机器人' : '当前机器人', avatar: '', instructions: `${id}规则`, nodeId: 'local', profile: 'default', archived: false }))
+  const agents = ['current-bot', 'target-bot'].map(id => ({ id, name: id === 'target-bot' ? '目标机器人' : '当前机器人', avatar: id === 'current-bot' ? 'avatar-current' : '', instructions: `${id}规则`, nodeId: 'local', profile: 'default', archived: false }))
   vi.stubGlobal('EventSource', class { addEventListener() {} close() {} })
   vi.stubGlobal('EventSource', class { addEventListener() {} close() {} })
   vi.mocked(apiRequest).mockImplementation(async path => {
   vi.mocked(apiRequest).mockImplementation(async path => {
     if (path === '/api/app/capabilities') return { features: [], csrfToken: 'fixture-csrf' } as never
     if (path === '/api/app/capabilities') return { features: [], csrfToken: 'fixture-csrf' } as never
@@ -41,6 +42,12 @@ it.each(['direct', 'group'] as const)('opens and saves the menu target %s while
     stubs: { WorkspaceShell: { template: '<div><slot name="sidebar" /><slot /></div>' }, ComposerShell: { template: '<div />', methods: { filesSnapshot: () => [], attachFiles: async () => {} } }, Teleport: false },
     stubs: { WorkspaceShell: { template: '<div><slot name="sidebar" /><slot /></div>' }, ComposerShell: { template: '<div />', methods: { filesSnapshot: () => [], attachFiles: async () => {} } }, Teleport: false },
   } })
   } })
   await flushPromises()
   await flushPromises()
+  expect(wrapper.findComponent(WorkspaceMessageTimeline).props()).toMatchObject({
+    title: '当前机器人',
+    headerAvatarName: '当前机器人',
+    headerAvatar: 'avatar-current',
+    headerAvatarKind: 'agent',
+  })
   wrapper.findComponent({ name: 'ConversationList' }).vm.$emit('settings', 'target')
   wrapper.findComponent({ name: 'ConversationList' }).vm.$emit('settings', 'target')
   await flushPromises()
   await flushPromises()
   expect(router.currentRoute.value.path).toBe('/conversations/current')
   expect(router.currentRoute.value.path).toBe('/conversations/current')

+ 36 - 0
tests/client/typingIndicator.test.ts

@@ -115,6 +115,42 @@ describe('message timeline typing state', () => {
     wrapper.unmount()
     wrapper.unmount()
   })
   })
 
 
+  it('keeps the workspace header avatar and name in sync with reactive identity props', async () => {
+    const wrapper = mount(WorkspaceMessageTimeline, {
+      props: {
+        identity: 'header-identity',
+        messages: [],
+        title: 'Apple Dev',
+        headerAvatarName: 'Apple Dev',
+        headerAvatar: 'avatar-initial',
+        headerAvatarState: 'working',
+      },
+      global: { stubs },
+    })
+
+    expect(wrapper.get('.timeline-header h2').text()).toBe('Apple Dev')
+    expect(wrapper.get('.timeline-header .agent-avatar-stub').attributes()).toMatchObject({
+      'data-name': 'Apple Dev',
+      'data-avatar': 'avatar-initial',
+      'data-state': 'working',
+    })
+
+    await wrapper.setProps({
+      title: 'iOS 发布',
+      headerAvatarName: 'iOS 发布',
+      headerAvatar: 'avatar-updated',
+      headerAvatarState: 'success',
+    })
+
+    expect(wrapper.get('.timeline-header h2').text()).toBe('iOS 发布')
+    expect(wrapper.get('.timeline-header .agent-avatar-stub').attributes()).toMatchObject({
+      'data-name': 'iOS 发布',
+      'data-avatar': 'avatar-updated',
+      'data-state': 'success',
+    })
+    wrapper.unmount()
+  })
+
   it.each(timelines)('reserves the empty logo aspect ratio before the image loads ($name)', ({ component }) => {
   it.each(timelines)('reserves the empty logo aspect ratio before the image loads ($name)', ({ component }) => {
     const wrapper = mount(component, {
     const wrapper = mount(component, {
       props: {
       props: {