فهرست منبع

feat: 增加正在思考动画反馈

samien 2 ماه پیش
والد
کامیت
e558bf2450

+ 36 - 1
src/client/components/messages/MessageTimeline.vue

@@ -1,5 +1,5 @@
 <script setup lang="ts">
-import { computed, nextTick, onMounted, ref, watch } from 'vue'
+import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
 import AppIcon from '@/components/common/AppIcon.vue'
 import EmptyState from '@/components/common/EmptyState.vue'
 import InteractionCard from './InteractionCard.vue'
@@ -29,6 +29,7 @@ const props = withDefaults(defineProps<{
   showAssistantIdentity?: boolean
   interaction?: UiInteraction | null
   mentionNames?: string[]
+  thinking?: boolean
 }>(), {
   title: '',
   subtitle: '',
@@ -46,6 +47,7 @@ const props = withDefaults(defineProps<{
   showAssistantIdentity: true,
   interaction: null,
   mentionNames: () => [],
+  thinking: false,
 })
 
 const emit = defineEmits<{
@@ -62,8 +64,16 @@ const scroller = ref<HTMLElement | null>(null)
 const pinnedToBottom = ref(true)
 const showJump = ref(false)
 const copiedMessageId = ref('')
+const thinkingElapsedMs = ref(0)
 const timelineRows = computed(() => buildMessageTimelineRows(props.messages))
+const showThinkingIndicator = computed(() => {
+  if (!props.thinking) return false
+  const current = [...props.messages].reverse().find(message => message.role === 'assistant' && message.status === 'streaming')
+  return !current?.content.trim() && !current?.reasoning?.trim() && !current?.tools?.length
+})
 let copyResetTimer: number | undefined
+let thinkingTimer: number | undefined
+let thinkingStartedAt = 0
 
 const formatTime = formatMessageTime
 
@@ -169,6 +179,11 @@ function deliveryLabel(status?: UiMessage['status']): string {
   } as Partial<Record<NonNullable<UiMessage['status']>, string>>)[status || 'settled'] || ''
 }
 
+function formatThinkingElapsed(value: number): string {
+  const seconds = Math.max(0, Math.floor(value / 1000))
+  return `${Math.floor(seconds / 60).toString().padStart(2, '0')}:${(seconds % 60).toString().padStart(2, '0')}`
+}
+
 watch(() => props.messages.length, async () => {
   if (!pinnedToBottom.value) return
   await nextTick()
@@ -181,7 +196,19 @@ watch(() => props.messages.at(-1)?.content, async () => {
   scrollToBottom('auto')
 })
 
+watch(showThinkingIndicator, visible => {
+  if (thinkingTimer !== undefined) window.clearInterval(thinkingTimer)
+  thinkingTimer = undefined
+  if (!visible) {
+    thinkingElapsedMs.value = 0
+    return
+  }
+  thinkingStartedAt = Date.now()
+  thinkingTimer = window.setInterval(() => { thinkingElapsedMs.value = Date.now() - thinkingStartedAt }, 250)
+}, { immediate: true })
+
 onMounted(() => nextTick(() => scrollToBottom('auto')))
+onBeforeUnmount(() => { if (thinkingTimer !== undefined) window.clearInterval(thinkingTimer) })
 
 defineExpose({ scrollToMessage, scrollToAnchor, scrollToBottom })
 </script>
@@ -325,6 +352,11 @@ defineExpose({ scrollToMessage, scrollToAnchor, scrollToBottom })
         </template>
         </template>
 
+        <div v-if="showThinkingIndicator" class="thinking-indicator" role="status" aria-live="polite" aria-label="正在思考">
+          <span class="thinking-indicator__icon"><AppIcon name="brain" :size="15" /><i /><i /><i /></span>
+          <span><strong>正在思考</strong><small>{{ formatThinkingElapsed(thinkingElapsedMs) }}</small></span>
+        </div>
+
         <InteractionCard
           v-if="interaction"
           :interaction="interaction"
@@ -363,6 +395,8 @@ defineExpose({ scrollToMessage, scrollToAnchor, scrollToBottom })
 .dark .message--user .message__body { background: #2d323a; }
 .message--user .message__meta { display: none; }
 .message__delivery { display: flex; min-height: 18px; align-items: center; justify-content: flex-end; gap: 4px; margin: 5px 1px -4px; color: var(--text-muted); font-size: 9px; }.message__delivery--accepted { color: var(--success); }.message__delivery--failed, .message__delivery--unknown-receipt { color: var(--danger); }
+.thinking-indicator { display: flex; min-height: 42px; align-items: center; gap: 10px; margin: 5px 0 20px; color: var(--text-muted); }.thinking-indicator__icon { display: flex; width: 30px; height: 30px; align-items: center; justify-content: center; gap: 2px; border-radius: 10px; background: var(--surface-soft); animation: thinking-breathe 1.8s ease-in-out infinite; }.thinking-indicator__icon i { width: 3px; height: 3px; border-radius: 50%; background: currentColor; animation: thinking-dot 1.2s ease-in-out infinite; }.thinking-indicator__icon i:nth-of-type(2) { animation-delay: .16s; }.thinking-indicator__icon i:nth-of-type(3) { animation-delay: .32s; }.thinking-indicator > span:last-child { display: flex; flex-direction: column; gap: 2px; }.thinking-indicator strong { color: var(--text-secondary); font-size: 11px; font-weight: 600; background: linear-gradient(90deg, var(--text-muted), var(--text-primary), var(--text-muted)); background-size: 200% 100%; background-clip: text; -webkit-background-clip: text; -webkit-text-fill-color: transparent; animation: thinking-shimmer 2.2s linear infinite; }.thinking-indicator small { font-size: 9px; font-variant-numeric: tabular-nums; }
+@keyframes thinking-breathe { 50% { opacity: .58; transform: scale(.94); } } @keyframes thinking-dot { 0%, 70%, 100% { opacity: .25; transform: translateY(0); } 35% { opacity: 1; transform: translateY(-2px); } } @keyframes thinking-shimmer { to { background-position: -200% 0; } }
 .message--system { justify-content: center; }.message--system .message__avatar, .message--system .message__meta { display: none; }.message--system .message__body { max-width: 82%; padding: 6px 10px; border: 1px solid var(--line); border-radius: 9px; color: var(--text-muted); text-align: center; }
 .delegation-event { min-width: 230px; max-width: 460px; text-align: left; }.delegation-event summary { display: flex; align-items: center; gap: 8px; cursor: pointer; list-style: none; }.delegation-event summary::-webkit-details-marker { display: none; }.delegation-event summary > span { display: flex; min-width: 0; flex-direction: column; gap: 2px; }.delegation-event strong { color: var(--text-secondary); font-size: 11px; font-weight: 650; }.delegation-event small { color: var(--text-muted); font-size: 9px; }.delegation-event :deep(.markdown) { max-height: 260px; margin-top: 9px; overflow: auto; color: var(--text-secondary); font-size: 10px; }
 .system-event { max-width: 460px; text-align: left; }.system-event summary { display: inline-flex; align-items: center; gap: 6px; cursor: pointer; list-style: none; color: var(--text-muted); font-size: 10px; }.system-event summary::-webkit-details-marker { display: none; }.system-event :deep(.markdown) { max-height: 220px; margin-top: 8px; overflow: auto; color: var(--text-secondary); font-size: 10px; }
@@ -392,4 +426,5 @@ defineExpose({ scrollToMessage, scrollToAnchor, scrollToBottom })
   .message-stack { padding: 18px 14px 30px; }.message { margin-bottom: 18px; }.message__body { max-width: calc(100% - 37px); }.message--user .message__body { max-width: 88%; }.message__content :deep(.markdown) { font-size: 13px; }
   .message__actions { opacity: .65; }.message__meta time { display: none; }
 }
+@media (prefers-reduced-motion: reduce) { .thinking-indicator__icon, .thinking-indicator__icon i, .thinking-indicator strong { animation: none; } }
 </style>

+ 2 - 2
src/client/stores/chat.ts

@@ -169,7 +169,7 @@ export const useChatStore = defineStore('chat', () => {
       : { id: storedSessionId, profile: oldState.route.profile, source: 'web', title: '新会话', messageCount: 0, toolCallCount: 0, startedAt: now, updatedAt: now }
     if (draftIndex >= 0) sessions.value.splice(draftIndex, 1, summary)
     else sessions.value.unshift(summary)
-    return migrated
+    return routes[newKey]
   }
 
   socket.onEvent(event => {
@@ -608,7 +608,7 @@ export const useChatStore = defineStore('chat', () => {
         if (running) state.isStreaming = true
       } else {
         state.isQueued = status === 'queued'
-        if (running) {
+        if (running || status === 'accepted') {
           state.isQueued = false
           state.isStreaming = true
         }

+ 1 - 0
src/client/views/ChatView.vue

@@ -402,6 +402,7 @@ watch(() => chat.activeSessionId, async id => {
         :connected="connected"
         :synced="chat.historySynced"
         :show-tools="showThinking"
+        :thinking="chat.isSending || chat.isStreaming"
         :show-assistant-identity="false"
         :interaction="interaction"
         empty-logo

+ 1 - 0
src/client/views/GroupsView.vue

@@ -201,6 +201,7 @@ watch(() => auth.activeProfile?.name, profile => { if (profile) restoreShowThink
         :connected="connected"
         :synced="!!groups.selectedRoom"
         :show-tools="showThinking"
+        :thinking="groups.isSending || groups.agents.some(agent => ['queued', 'running'].includes(agent.status))"
         :interaction="activeInteraction"
         :mention-names="mentionNames"
         empty-title="让多个 Agent 一起工作"

+ 9 - 0
tests/e2e/workspace.spec.ts

@@ -267,6 +267,15 @@ test('opens a local message file link as a floating preview card', async ({ page
   expect(stageHeight).toBeGreaterThan(500)
 })
 
+test('shows a thinking animation after submit until output starts', async ({ page }) => {
+  await page.goto('/chat/session-demo?profile=yaoyao')
+  await page.locator('.composer-textarea').fill('请开始思考')
+  await page.getByRole('button', { name: '发送消息' }).click()
+  await expect(page.locator('.thinking-indicator')).toBeVisible()
+  await expect(page.getByText('这是来自假 Gateway 的流式回复。', { exact: true })).toBeVisible()
+  await expect(page.locator('.thinking-indicator')).toHaveCount(0)
+})
+
 test('keeps the canonical logo and yaoyao-webui composer geometry', async ({ page }) => {
   const logo = page.locator('.rail__brand img')
   await expect(logo).toHaveAttribute('src', '/brand/AppIcon-1024.png')

+ 1 - 1
tests/fixtures/fake-hermes.mjs

@@ -144,7 +144,7 @@ server.on('upgrade', (request, socket, head) => {
           client.send(JSON.stringify({ jsonrpc: '2.0', method: 'event', params: { type: 'message.start', session_id: requestFrame.params.session_id, profile: 'yaoyao', payload: { message_id: 'stream-demo' } } }))
           client.send(JSON.stringify({ jsonrpc: '2.0', method: 'event', params: { type: 'message.delta', session_id: requestFrame.params.session_id, profile: 'yaoyao', payload: { message_id: 'stream-demo', delta: '这是来自假 Gateway 的流式回复。' } } }))
           client.send(JSON.stringify({ jsonrpc: '2.0', method: 'event', params: { type: 'message.complete', session_id: requestFrame.params.session_id, profile: 'yaoyao', payload: { message_id: 'stream-demo', text: '这是来自假 Gateway 的流式回复。', status: 'complete' } } }))
-        }, 20)
+        }, 1200)
         return
       }
       respond({ ok: true })