Pārlūkot izejas kodu

fix(chat): 统一桌面等待输入动画

Samien 1 mēnesi atpakaļ
vecāks
revīzija
07aaacfc7c

+ 83 - 0
src/client/components/common/TypingIndicator.vue

@@ -0,0 +1,83 @@
+<script setup lang="ts">
+import AgentAvatar from './AgentAvatar.vue'
+
+defineProps<{
+  avatarName: string
+  avatar?: string
+  elapsedLabel: string
+}>()
+</script>
+
+<template>
+  <div
+    class="thinking-indicator"
+    role="status"
+    aria-live="polite"
+    aria-label="机器人正在输入"
+    data-testid="chat-run-thinking-dots"
+  >
+    <AgentAvatar
+      class="thinking-indicator__avatar"
+      :name="avatarName"
+      :avatar="avatar"
+      :size="20"
+      state="working"
+      aria-hidden="true"
+    />
+    <span class="thinking-indicator__dots" aria-hidden="true">
+      <i
+        v-for="index in 3"
+        :key="index"
+        class="thinking-indicator__dot"
+        :style="{ '--dot-index': index - 1 }"
+      />
+    </span>
+    <small class="thinking-indicator__elapsed">{{ elapsedLabel }}</small>
+  </div>
+</template>
+
+<style scoped>
+.thinking-indicator {
+  display: flex;
+  min-height: 34px;
+  align-items: center;
+  margin: 5px 0 20px;
+  color: var(--text-muted);
+}
+
+.thinking-indicator__dots {
+  display: inline-flex;
+  align-items: center;
+  gap: 5px;
+  padding: 8px 12px;
+  border-radius: 999px;
+  background: color-mix(in srgb, var(--text-secondary) 12%, transparent);
+}
+
+.thinking-indicator__dot {
+  width: 6.5px;
+  height: 6.5px;
+  border-radius: 50%;
+  background: color-mix(in srgb, var(--accent) 85%, transparent);
+  animation: thinking-dot-wave 1s cubic-bezier(.37, 0, .63, 1) infinite;
+  animation-delay: calc(var(--dot-index) * -.16s);
+}
+
+.thinking-indicator__elapsed {
+  color: var(--text-muted);
+  font-size: 10px;
+  font-variant-numeric: tabular-nums;
+}
+
+@keyframes thinking-dot-wave {
+  0%, 100% { transform: scale(.35); }
+  50% { transform: scale(1); }
+}
+
+@media (prefers-reduced-motion: reduce) {
+  .thinking-indicator__dot {
+    animation: none;
+    transform: none;
+  }
+}
+</style>

+ 16 - 12
src/client/components/messages/MessageTimeline.vue

@@ -3,6 +3,7 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
 import AppIcon from '@/components/common/AppIcon.vue'
 import AgentAvatar from '@/components/common/AgentAvatar.vue'
 import EmptyState from '@/components/common/EmptyState.vue'
+import TypingIndicator from '@/components/common/TypingIndicator.vue'
 import InteractionCard from './InteractionCard.vue'
 import MarkdownContent from './MarkdownContent.vue'
 import ToolTrace from './ToolTrace.vue'
@@ -77,12 +78,15 @@ const copiedMessageId = ref('')
 const copyFailedMessageId = 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')
-  // A tool trace is progress detail, not assistant prose. Keep the bottom
-  // activity indicator visible while that tool-only stream segment is active.
-  return !current?.content.trim() && !current?.reasoning?.trim()
+const showThinkingIndicator = computed(() => props.thinking)
+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 thinkingAvatar = computed(() => {
+  const profile = thinkingAgent.value?.profile
+  return profile ? props.agentAvatars[profile] || '' : ''
 })
 let copyResetTimer: number | undefined
 let thinkingTimer: number | undefined
@@ -388,9 +392,12 @@ defineExpose({ scrollToMessage, scrollToAnchor, scrollToBottom, isFollowingBotto
         </template>
         </template>
 
-        <div v-if="showThinkingIndicator" class="thinking-indicator" role="status" aria-live="polite" aria-label="正在思考">
-          <span><strong>正在思考</strong><small>{{ formatThinkingElapsed(thinkingElapsedMs) }}</small></span>
-        </div>
+        <TypingIndicator
+          v-if="showThinkingIndicator"
+          :avatar-name="thinkingAvatarName"
+          :avatar="thinkingAvatar"
+          :elapsed-label="formatThinkingElapsed(thinkingElapsedMs)"
+        />
 
         <InteractionCard
           v-if="interaction"
@@ -434,8 +441,6 @@ defineExpose({ scrollToMessage, scrollToAnchor, scrollToBottom, isFollowingBotto
 .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: 10px; }.message__delivery--accepted { color: var(--success); }.message__delivery--failed, .message__delivery--unknown-receipt { color: var(--danger); }
-.thinking-indicator { display: flex; min-height: 34px; align-items: center; margin: 5px 0 20px; color: var(--text-muted); }.thinking-indicator > span { display: inline-flex; align-items: baseline; gap: 7px; }.thinking-indicator strong { color: var(--text-secondary); font-size: 12px; 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: 10px; font-variant-numeric: tabular-nums; }
-@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: 12px; font-weight: 650; }.delegation-event small { color: var(--text-muted); font-size: 10px; }.delegation-event :deep(.markdown) { max-height: 260px; margin-top: 9px; overflow: auto; color: var(--text-secondary); font-size: 11px; }
 .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: 11px; }.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: 11px; }
@@ -465,5 +470,4 @@ defineExpose({ scrollToMessage, scrollToAnchor, scrollToBottom, isFollowingBotto
   .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__actions { opacity: .65; }.message__meta time { display: none; }
 }
-@media (prefers-reduced-motion: reduce) { .thinking-indicator strong { animation: none; } }
 </style>

+ 17 - 11
src/client/components/workspace/WorkspaceMessageTimeline.vue

@@ -3,6 +3,7 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
 import AppIcon from '@/components/common/AppIcon.vue'
 import AgentAvatar from '@/components/common/AgentAvatar.vue'
 import EmptyState from '@/components/common/EmptyState.vue'
+import TypingIndicator from '@/components/common/TypingIndicator.vue'
 import InteractionCard from '../messages/InteractionCard.vue'
 import WorkspaceApprovalCard from './WorkspaceApprovalCard.vue'
 import MarkdownContent from '../messages/MarkdownContent.vue'
@@ -132,11 +133,16 @@ function scheduleFollow() {
   })
 }
 const showThinkingIndicator = computed(() => {
-  if (!props.thinking) return false
-  const current = [...props.messages].reverse().find(message => message.role === 'assistant' && message.status === 'streaming')
-  // A tool trace is progress detail, not assistant prose. Keep the bottom
-  // activity indicator visible while that tool-only stream segment is active.
-  return !current?.content.trim() && !current?.reasoning?.trim()
+  return props.thinking
+})
+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 thinkingAvatar = computed(() => {
+  const profile = thinkingAgent.value?.profile
+  return profile ? props.agentAvatars[profile] || '' : ''
 })
 let copyResetTimer: number | undefined
 let thinkingTimer: number | undefined
@@ -473,9 +479,12 @@ defineExpose({ scrollToMessage, scrollToAnchor, scrollToBottom, isFollowingBotto
         </template>
         </template>
 
-        <div v-if="showThinkingIndicator" class="thinking-indicator" role="status" aria-live="polite" aria-label="正在思考">
-          <span><strong>正在思考</strong><small>{{ formatThinkingElapsed(thinkingElapsedMs) }}</small></span>
-        </div>
+        <TypingIndicator
+          v-if="showThinkingIndicator"
+          :avatar-name="thinkingAvatarName"
+          :avatar="thinkingAvatar"
+          :elapsed-label="formatThinkingElapsed(thinkingElapsedMs)"
+        />
 
         <WorkspaceApprovalCard v-if="interaction?.kind === 'approval'" :prompt="interaction.prompt" :agent-name="approvalAgentName" :busy="interactionBusy" :error="interactionError" @choose="emit('approvalChoice', $event)" />
         <InteractionCard
@@ -527,8 +536,6 @@ defineExpose({ scrollToMessage, scrollToAnchor, scrollToBottom, isFollowingBotto
 .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: 10px; }.message__delivery--accepted { color: var(--success); }.message__delivery--failed, .message__delivery--unknown-receipt { color: var(--danger); }
-.thinking-indicator { display: flex; min-height: 34px; align-items: center; margin: 5px 0 20px; color: var(--text-muted); }.thinking-indicator > span { display: inline-flex; align-items: baseline; gap: 7px; }.thinking-indicator strong { color: var(--text-secondary); font-size: 12px; 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: 10px; font-variant-numeric: tabular-nums; }
-@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: 12px; font-weight: 650; }.delegation-event small { color: var(--text-muted); font-size: 10px; }.delegation-event :deep(.markdown) { max-height: 260px; margin-top: 9px; overflow: auto; color: var(--text-secondary); font-size: 11px; }
 .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: 11px; }.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: 11px; }
@@ -558,5 +565,4 @@ defineExpose({ scrollToMessage, scrollToAnchor, scrollToBottom, isFollowingBotto
   .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__actions { opacity: .65; }.message__meta time { display: none; }
 }
-@media (prefers-reduced-motion: reduce) { .thinking-indicator strong { animation: none; } }
 </style>

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

@@ -0,0 +1,151 @@
+import { mount } from '@vue/test-utils'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { defineComponent, nextTick, type Component } from 'vue'
+import TypingIndicator from '@/components/common/TypingIndicator.vue'
+import MessageTimeline from '@/components/messages/MessageTimeline.vue'
+import WorkspaceMessageTimeline from '@/components/workspace/WorkspaceMessageTimeline.vue'
+import type { UiMessage } from '@/components/messages/types'
+
+const AgentAvatarStub = defineComponent({
+  props: {
+    name: { type: String, required: true },
+    avatar: { type: String, default: '' },
+    size: { type: Number, required: true },
+    state: { type: String, required: true },
+  },
+  template: `<span class="agent-avatar-stub" :data-name="name" :data-avatar="avatar" :data-size="size" :data-state="state" />`,
+})
+
+const stubs = {
+  AgentAvatar: AgentAvatarStub,
+  EmptyState: true,
+  InteractionCard: true,
+  MarkdownContent: true,
+  ToolTrace: true,
+  TurnTrace: true,
+  WorkspaceApprovalCard: true,
+}
+
+const user: UiMessage = { id: 'user', role: 'user', content: '请继续' }
+
+function streamingAssistant(): UiMessage {
+  return {
+    id: 'assistant-streaming',
+    role: 'assistant',
+    author: '流式助手',
+    profile: 'streaming-profile',
+    content: '正文已经开始输出',
+    reasoning: '思考也已经出现',
+    status: 'streaming',
+    tools: [{ id: 'tool', name: 'read_file', status: 'running' }],
+  }
+}
+
+function mountTimeline(component: Component, messages: UiMessage[], thinking: boolean) {
+  return mount(component, {
+    props: {
+      ...(component === WorkspaceMessageTimeline ? { identity: 'timeline' } : {}),
+      messages,
+      thinking,
+      agentAvatars: {
+        'streaming-profile': 'avatar-streaming',
+        'history-profile': 'avatar-history',
+      },
+    },
+    global: { stubs },
+  })
+}
+
+describe('TypingIndicator', () => {
+  it('renders a compact avatar, three dots, elapsed time and status semantics', () => {
+    const wrapper = mount(TypingIndicator, {
+      props: { avatarName: '丫头', avatar: '', elapsedLabel: '3 秒' },
+    })
+
+    expect(wrapper.attributes('role')).toBe('status')
+    expect(wrapper.attributes('aria-live')).toBe('polite')
+    expect(wrapper.attributes('aria-label')).toBe('机器人正在输入')
+    expect(wrapper.attributes('data-testid')).toBe('chat-run-thinking-dots')
+    expect(wrapper.get('.agent-avatar').attributes('style')).toContain('width: 20px')
+    expect(wrapper.findAll('.thinking-indicator__dot')).toHaveLength(3)
+    expect(wrapper.text()).toContain('3 秒')
+    wrapper.unmount()
+  })
+})
+
+describe('message timeline typing state', () => {
+  const timelines: Array<{ name: string; component: Component }> = [
+    { name: 'ordinary', component: MessageTimeline },
+    { name: 'workspace', component: WorkspaceMessageTimeline },
+  ]
+
+  beforeEach(() => {
+    Element.prototype.scrollTo = vi.fn()
+    vi.stubGlobal('ResizeObserver', class {
+      observe() {}
+      unobserve() {}
+      disconnect() {}
+    })
+  })
+
+  afterEach(() => {
+    delete (Element.prototype as { scrollTo?: () => void }).scrollTo
+    vi.unstubAllGlobals()
+    vi.useRealTimers()
+  })
+
+  it.each(timelines)('keeps the typing row visible after content, reasoning and tools start ($name)', async ({ component }) => {
+    const wrapper = mountTimeline(component, [user, streamingAssistant()], true)
+
+    const indicator = wrapper.get('[data-testid="chat-run-thinking-dots"]')
+    expect(indicator.attributes('role')).toBe('status')
+    expect(indicator.attributes('aria-label')).toBe('机器人正在输入')
+    expect(wrapper.findAll('.thinking-indicator__dot')).toHaveLength(3)
+    expect(wrapper.text()).toContain('0 秒')
+    const avatar = wrapper.get('[data-testid="chat-run-thinking-dots"] .agent-avatar-stub')
+    expect(avatar.attributes('data-name')).toBe('流式助手')
+    expect(avatar.attributes('data-avatar')).toBe('avatar-streaming')
+    wrapper.unmount()
+  })
+
+  it.each(timelines)('hides the typing row when the run is no longer thinking ($name)', ({ component }) => {
+    const wrapper = mountTimeline(component, [user, streamingAssistant()], false)
+
+    expect(wrapper.find('[data-testid="chat-run-thinking-dots"]').exists()).toBe(false)
+    wrapper.unmount()
+  })
+
+  it.each(timelines)('falls back from streaming agent to history agent and default agent ($name)', ({ component }) => {
+    const streaming = mountTimeline(component, [
+      user,
+      { id: 'assistant-history', role: 'assistant', author: '历史助手', profile: 'history-profile', content: '旧回复' },
+      streamingAssistant(),
+    ], true)
+    expect(streaming.get('[data-testid="chat-run-thinking-dots"] .agent-avatar-stub').attributes('data-name')).toBe('流式助手')
+    streaming.unmount()
+
+    const history = mountTimeline(component, [
+      user,
+      { id: 'assistant-history', role: 'assistant', author: '历史助手', profile: 'history-profile', content: '旧回复' },
+    ], true)
+    expect(history.get('[data-testid="chat-run-thinking-dots"] .agent-avatar-stub').attributes('data-name')).toBe('历史助手')
+    expect(history.get('[data-testid="chat-run-thinking-dots"] .agent-avatar-stub').attributes('data-avatar')).toBe('avatar-history')
+    history.unmount()
+
+    const fallback = mountTimeline(component, [user], true)
+    expect(fallback.get('[data-testid="chat-run-thinking-dots"] .agent-avatar-stub').attributes('data-name')).toBe('夭')
+    expect(fallback.get('[data-testid="chat-run-thinking-dots"] .agent-avatar-stub').attributes('data-avatar')).toBe('')
+    fallback.unmount()
+  })
+
+  it('counts elapsed time while thinking remains active', async () => {
+    vi.useFakeTimers()
+    const wrapper = mountTimeline(MessageTimeline, [user, streamingAssistant()], true)
+
+    expect(wrapper.text()).toContain('0 秒')
+    vi.advanceTimersByTime(1_100)
+    await nextTick()
+    expect(wrapper.text()).toContain('1 秒')
+    wrapper.unmount()
+  })
+})

+ 8 - 3
tests/e2e/workspace-chat.spec.ts

@@ -188,18 +188,23 @@ test('an idle Web viewer follows an iOS-origin run and restores thinking after r
   await page.goto(`/chat/${session.stored_session_id}?profile=default`)
   await subscribed
   await command('prompt.submit', { session_id: session.session_id, text: '[cross-client-live] 检查文件' })
-  await expect(page.getByRole('status', { name: '正在思考', exact: true })).toBeVisible()
+  const typing = page.getByTestId('chat-run-thinking-dots')
+  await expect(typing).toBeVisible()
+  await expect(typing.locator('.agent-avatar')).toBeVisible()
+  await expect(typing.locator('.thinking-indicator__dot')).toHaveCount(3)
+  await expect(typing).toContainText('秒')
   await page.getByText('思考与工具', { exact: true }).click()
   await expect(page.getByText('read_file', { exact: true })).toBeVisible()
+  await expect(typing).toBeVisible()
   await page.screenshot({ path: testInfo.outputPath('ios-run-web-thinking-tools.png') })
 
   // This Web page has never submitted a prompt or written an in-flight marker.
   await page.evaluate(() => sessionStorage.clear())
   await page.reload()
-  await expect(page.getByRole('status', { name: '正在思考', exact: true })).toBeVisible()
+  await expect(typing).toBeVisible()
   await page.request.post('http://127.0.0.1:19120/__release')
   await expect(page.locator('.message--assistant')).toContainText('会话独立保存')
-  await expect(page.getByRole('status', { name: '正在思考', exact: true })).toHaveCount(0)
+  await expect(typing).toHaveCount(0)
   await page.screenshot({ path: testInfo.outputPath('ios-run-web-completed.png') })
 })
 

+ 6 - 4
tests/e2e/workspace.spec.ts

@@ -835,17 +835,19 @@ 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 }) => {
+test('keeps a thinking animation while output is streaming', async ({ page }) => {
   await page.goto('/chat/session-demo?profile=yaoyao')
   await expect(page.getByText('已整理完成。下面是', { exact: false })).toBeVisible()
   const replies = page.getByText('这是来自假 Gateway 的流式回复。', { exact: true })
   const count = await replies.count()
   await page.locator('.composer-textarea').fill('请开始思考')
   await page.getByRole('button', { name: '发送消息' }).click()
-  await expect(page.locator('.thinking-indicator')).toBeVisible()
+  const typing = page.getByTestId('chat-run-thinking-dots')
+  await expect(typing).toBeVisible()
   await expect(replies).toHaveCount(count + 1)
   await expect(replies.last()).toBeVisible()
-  await expect(page.locator('.thinking-indicator')).toHaveCount(0)
+  await expect(typing).toBeVisible()
+  await expect(typing).toHaveCount(0)
 })
 
 test.skip('uses HTTP commands and SSE for chat and team events without opening client WebSockets', async ({ page }, testInfo) => {
@@ -876,7 +878,7 @@ test('keeps streamed text below sealed interim commentary and its tool trace', a
   const trace = page.locator('.turn-trace').last()
   await expect(interim).toBeVisible()
   await expect(trace).toHaveClass(/turn-trace--running/)
-  await expect(page.locator('.thinking-indicator')).toBeVisible()
+  await expect(page.getByTestId('chat-run-thinking-dots')).toBeVisible()
   await expect(current).toBeVisible()
 
   const [interimBox, traceBox, currentBox] = await Promise.all([

+ 5 - 2
tests/fixtures/fake-hermes.mjs

@@ -492,6 +492,7 @@ server.on('upgrade', (request, socket, head) => {
       if (requestFrame.method === 'session.usage') return respond({ context_used: 12500, context_max: 114688, total: 12500, input: 9000, output: 3500 })
       if (requestFrame.method === 'prompt.submit') {
         respond({ status: 'accepted' })
+        const slowStream = requestFrame.params.text === '请开始思考'
         setTimeout(() => {
           const emit = (type, payload) => client.send(JSON.stringify({
             jsonrpc: '2.0', method: 'event',
@@ -520,8 +521,10 @@ 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' } }))
           client.send(JSON.stringify({ jsonrpc: '2.0', method: 'event', params: { type: 'message.delta', session_id: requestFrame.params.session_id, profile: 'yaoyao', payload: { text: '这是来自假 Gateway 的流式回复。' } } }))
-          client.send(JSON.stringify({ jsonrpc: '2.0', method: 'event', params: { type: 'message.complete', session_id: requestFrame.params.session_id, profile: 'yaoyao', payload: { text: '这是来自假 Gateway 的流式回复。', status: 'complete' } } }))
-        }, requestFrame.params.text === '请开始思考' ? 3500 : 1200)
+          setTimeout(() => {
+            client.send(JSON.stringify({ jsonrpc: '2.0', method: 'event', params: { type: 'message.complete', session_id: requestFrame.params.session_id, profile: 'yaoyao', payload: { text: '这是来自假 Gateway 的流式回复。', status: 'complete' } } }))
+          }, slowStream ? 1_200 : 0)
+        }, slowStream ? 3_500 : 1_200)
         return
       }
       respond({ ok: true })