Kaynağa Gözat

统一等待头像身份并修复绕身彩带

Samien 1 ay önce
ebeveyn
işleme
568b7a7

+ 8 - 6
src/client/components/common/AgentAvatar.vue

@@ -18,13 +18,14 @@ const hasImage = computed(() => identity.value.avatarMode === 'image' && !!ident
 const radius = computed(() => identity.value.imageCrop === 'circle' ? '50%' : identity.value.imageCrop === 'square' ? '0' : '22%')
 const states: Record<MascotState, CursorState> = {idle:'idle',working:'working',loading:'loading',waiting:'alerting',notifying:'notifying',success:'happy',failure:'sad'}
 const runtimeActive = computed(() => props.state !== 'idle' && !beatEnded.value)
+const imageActivity = computed(() => hasImage.value && runtimeActive.value && ['working', 'loading'].includes(props.state))
 const state = computed<CursorState>(() => runtimeActive.value ? states[props.state] : oneShot.value ?? identity.value.expression)
 const live = computed(() => visible.value && foreground.value && !reduced.value && props.animated !== false && (props.animated === true || props.size >= 56 || runtimeActive.value || !!oneShot.value || hover.value))
 const options = computed(() => ({ state: state.value, paused: !live.value, color: identity.value.color, gaze: gaze.value,
-  silhouette: identity.value.bodyId ? LAOA_BODIES[identity.value.bodyId] : MASCOT_SILHOUETTES[identity.value.shape === 'ellipse' ? 'oval' : identity.value.shape], fixedTime: props.fixedTime, expression: props.expressionIndex,
-  ribbons: runtimeActive.value || state.value !== 'working' }))
+  silhouette: hasImage.value ? MASCOT_SILHOUETTES.circle : identity.value.bodyId ? LAOA_BODIES[identity.value.bodyId] : MASCOT_SILHOUETTES[identity.value.shape === 'ellipse' ? 'oval' : identity.value.shape], fixedTime: props.fixedTime, expression: props.expressionIndex,
+  effectsOnly: hasImage.value, ribbons: runtimeActive.value || state.value !== 'working' }))
 function sync() {
-  if (!svg.value || hasImage.value) { renderer?.dispose();renderer=undefined;return }
+  if (!svg.value || (hasImage.value && !imageActivity.value)) { renderer?.dispose();renderer=undefined;return }
   if (!renderer) renderer=mountCursorAvatar(svg.value, options.value)
   else renderer.update(options.value)
 }
@@ -42,7 +43,7 @@ watch(() => [props.state, props.activityKey] as const, ([next]) => {
   clearTimeout(beat);beatEnded.value=false;oneShot.value=undefined
   if(props.fixedTime === undefined && ['success','failure','notifying'].includes(next)) beat=setTimeout(()=>{beatEnded.value=true},1400)
 },{immediate:true})
-watch([options,svg,hasImage],sync,{flush:'post'})
+watch([options,svg,imageActivity],sync,{flush:'post'})
 onMounted(()=>{
   visibility();media=window.matchMedia?.('(prefers-reduced-motion: reduce)');motion();media?.addEventListener('change',motion)
   document.addEventListener('visibilitychange',visibility)
@@ -52,11 +53,12 @@ onMounted(()=>{
 onBeforeUnmount(()=>{renderer?.dispose();observer?.disconnect();clearTimeout(beat);media?.removeEventListener('change',motion);document.removeEventListener('visibilitychange',visibility)})
 </script>
 <template>
-  <span ref="host" class="agent-avatar" :class="[`agent-avatar--${props.state}`, {'agent-avatar--image':hasImage}]" :style="{width:`${size}px`,height:`${size}px`}" role="img" :aria-label="`${name} 的头像`" @pointermove="pointer" @pointerleave="leave" :data-animated="live && !hasImage" :data-expression="state">
+  <span ref="host" class="agent-avatar" :class="[`agent-avatar--${props.state}`, {'agent-avatar--image':hasImage, 'agent-avatar--image-active': imageActivity && live}]" :style="{width:`${size}px`,height:`${size}px`}" role="img" :aria-label="`${name} 的头像`" @pointermove="pointer" @pointerleave="leave" :data-animated="live && (!hasImage || imageActivity)" :data-expression="state">
     <img @error="imageFailed = true" v-if="hasImage" :src="identity.imageDataURL" alt="" :style="{borderRadius:radius}" />
-    <svg v-else ref="svg" aria-hidden="true" />
+    <svg v-show="!hasImage || imageActivity" ref="svg" aria-hidden="true" />
   </span>
 </template>
 <style scoped>
 .agent-avatar{display:block;position:relative;flex:0 0 auto;overflow:visible;line-height:1;background:transparent}.agent-avatar img,.agent-avatar svg{display:block;width:100%;height:100%;object-fit:cover}
+.agent-avatar svg{overflow:visible;pointer-events:none}.agent-avatar--image svg{position:absolute;inset:0}.agent-avatar--image-active img{transform:scale(.72)}
 </style>

+ 9 - 1
src/client/components/common/maus/cursor-engine.ts

@@ -920,6 +920,8 @@ export interface CursorOptions {
   fixedTime?: number
   /** Live activity rings. A saved "working" face does not wear them. */
   ribbons?: boolean
+  /** Draw activity around an uploaded avatar without drawing a replacement face. */
+  effectsOnly?: boolean
 }
 type Engine = ReturnType<typeof newEngine>
 function newEngine() {
@@ -1037,6 +1039,13 @@ export function mountCursorAvatar(svg: SVGSVGElement, initial: CursorOptions) {
     svg.setAttribute('viewBox', next.silhouette.viewBox ?? VIEW_BOX)
     const e=engine.current, now=performance.now();paintRef.current=next.color
     e.props={...e.props,state:next.state,expression:next.expression,gaze:next.gaze,paused:next.paused,motionStrength:next.paused?0:1,ribbons:next.ribbons!==false}
+    const [vx,vy,vw,vh] = (next.silhouette.viewBox ?? VIEW_BOX).split(/\s+/).map(Number)
+    const cx = vx + vw / 2, cy = vy + vh / 2
+    const hasTrails = !next.paused && next.ribbons !== false && !!EFFECTS[next.state]?.trails
+    bodyGroup.current.style.visibility = next.effectsOnly ? 'hidden' : ''
+    // Leave room for live ribbons while keeping the saved silhouette and resting size.
+    bodyContent.current.setAttribute('transform', hasTrails ? `translate(${cx} ${cy}) scale(.72) translate(${-cx} ${-cy})` : '')
+    trailLayer.current.setAttribute('transform', `translate(${cx} ${cy}) scale(${Math.min(vw,vh) / FACE_BOX}) translate(${-FACE_BOX / 2} ${-FACE_BOX / 2})`)
     const outline=node<SVGGElement>('outline').current, clip=node<SVGClipPathElement>('clip').current, face=node<SVGGElement>('face').current
     const body=next.silhouette.body.replace(/\{\{GRADIENT\}\}/g,next.color)
     if(lastBody!==body){outline.innerHTML=body;lastBody=body}
@@ -1072,4 +1081,3 @@ function blinkScale(e: { blinkStart: number | null }, now: number) {
   // Fast close, slower open.
   return Math.max(t < 0.42 ? 1 - t / 0.42 : (t - 0.42) / 0.58, 0.04)
 }
-

+ 3 - 1
src/client/components/workspace/WorkspaceMessageTimeline.vue

@@ -49,6 +49,7 @@ const props = withDefaults(defineProps<{
   agentAvatars?: Record<string, string>
   agentStates?: Record<string, AvatarState>
   thinking?: boolean
+  thinkingIdentity?: { name: string; avatar: string }
   allowBranch?: boolean
   readOnly?: boolean
 }>(), {
@@ -150,8 +151,9 @@ const thinkingAgent = computed(() =>
   [...props.messages].reverse().find(message => message.role === 'assistant' && message.status === 'streaming')
     ?? [...props.messages].reverse().find(message => message.role === 'assistant'),
 )
-const thinkingAvatarName = computed(() => thinkingAgent.value?.author || thinkingAgent.value?.profile || '夭')
+const thinkingAvatarName = computed(() => props.thinkingIdentity?.name || thinkingAgent.value?.author || thinkingAgent.value?.profile || '夭')
 const thinkingAvatar = computed(() => {
+  if (props.thinkingIdentity) return props.thinkingIdentity.avatar
   const profile = thinkingAgent.value?.profile
   return profile ? props.agentAvatars[profile] || '' : ''
 })

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

@@ -254,6 +254,14 @@ const activeHeaderTitle = computed(() => activeHeaderAgent.value?.name || active
 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 thinkingIdentity = computed(() => {
+  const streamingAgentId = [...uiMessages.value].reverse().find(message => message.role === 'assistant' && message.status === 'streaming')?.profile
+  const agentId = activeHeaderAgent.value?.id || run.value?.activeAgentId || streamingAgentId
+    || active.value?.activeAgentId || Object.entries(active.value?.activeAgentStates ?? {}).find(([, state]) => state === 'running')?.[0]
+    || active.value?.memberIds[0]
+  const agent = agents.value.find(agent => agent.id === agentId)
+  return { name: agent?.name || active.value?.name || '机器人', avatar: agent?.avatar || (active.value?.kind === 'direct' ? active.value.avatar : '') || '' }
+})
 const isAgentDialog = computed(() => dialog.value === 'agent' || dialog.value === 'editAgent')
 const body = (v: unknown) => v as JsonValue
 async function saveProfileIdentity(input: ProfileIdentityInput) {
@@ -973,7 +981,7 @@ onBeforeUnmount(() => {
         :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"
-        :show-tools="showThinking" :allow-branch="false" :thinking="thinking"
+        :show-tools="showThinking" :allow-branch="false" :thinking="thinking" :thinking-identity="thinkingIdentity"
         :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)"

+ 17 - 0
tests/client/agentAvatar.test.ts

@@ -8,6 +8,23 @@ import MessageTimeline from '@/components/messages/MessageTimeline.vue'
 const transparentPng = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII='
 
 describe('AgentAvatar', () => {
+  it.each(['mascot', 'image'] as const)('keeps %s identity and animates ribbons only during live work', async (mode) => {
+    const identity = { ...defaultAgentIdentity('Bot'), shape: 'triangle' as const, color: '#1488ff',
+      avatarMode: mode, ...(mode === 'image' ? { imageDataURL: transparentPng } : {}) }
+    const wrapper = mount(AgentAvatar, { props: { name: 'Bot', avatar: encodeAgentAvatar(identity), state: 'working', fixedTime: 500 } })
+    expect(wrapper.findAll('[data-part=trails] path')).toHaveLength(3)
+    if (mode === 'image') {
+      expect(wrapper.get('img').attributes('src')).toBe(transparentPng)
+      expect((wrapper.get('[data-part=bodyGroup]').element as SVGElement).style.visibility).toBe('hidden')
+    } else {
+      expect(wrapper.get('[data-part=outline] path').attributes('fill')).toBe('#1488ff')
+      expect(wrapper.get('[data-part=bodyContent]').attributes('transform')).toContain('scale(.72)')
+    }
+    await wrapper.setProps({ state: 'idle', avatar: encodeAgentAvatar({ ...identity, expression: 'working' }) })
+    expect(wrapper.findAll('[data-part=trails] path')).toHaveLength(0)
+    wrapper.unmount()
+  })
+
   it('marks real images so caller backgrounds cannot show through transparent pixels', () => {
     const wrapper = mount(AgentAvatar, { props: { name: '丫头', avatar: transparentPng } })
 

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

@@ -153,6 +153,19 @@ describe('message timeline typing state', () => {
     wrapper.unmount()
   })
 
+  it('uses the current Bot before its first token and follows identity changes instead of old messages', async () => {
+    const wrapper = mountTimeline(WorkspaceMessageTimeline, [user], true)
+    await wrapper.setProps({ thinkingIdentity: { name: '当前 Bot', avatar: 'current-avatar' } })
+    const avatar = () => wrapper.get('[data-testid="chat-run-thinking-dots"] .agent-avatar-stub')
+    expect(avatar().attributes('data-avatar')).toBe('current-avatar')
+    await wrapper.setProps({ messages: [streamingAssistant(), user], thinkingIdentity: { name: '接手 Bot', avatar: 'next-avatar' } })
+    expect(avatar().attributes('data-name')).toBe('接手 Bot')
+    expect(avatar().attributes('data-avatar')).toBe('next-avatar')
+    await wrapper.setProps({ thinkingIdentity: { name: '接手 Bot', avatar: 'updated-avatar' } })
+    expect(avatar().attributes('data-avatar')).toBe('updated-avatar')
+    wrapper.unmount()
+  })
+
   it.each(timelines)('reserves the empty logo aspect ratio before the image loads ($name)', ({ component }) => {
     const wrapper = mount(component, {
       props: {