main: 增强 ChatView 消息处理与错误展示功能

- 调整消息归一化逻辑,添加对 delta 和 agent.message 的支持
- 实现长文本逐字显示效果,增强用户交互体验
- 新增系统错误展示机制,支持查看原始错误信息
- 改善状态更新逻辑,优化 FAILED 状态的处理
- 优化界面样式,增加系统消息气泡与错误卡片设计
This commit is contained in:
2025-12-19 02:35:07 +08:00
parent afb166dddf
commit 9b48ff3e3b
2 changed files with 721 additions and 33 deletions

View File

@@ -1,4 +1,4 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { createRouter, createMemoryHistory } from 'vue-router'
import ElementPlus from 'element-plus'
@@ -265,3 +265,362 @@ describe('ChatView run.status handling', () => {
expect(router.currentRoute.value.name).toBe('login')
})
})
describe('ChatView message.delta handling', () => {
beforeEach(() => {
localStorage.clear()
vi.clearAllMocks()
latestEventSource = null
globalThis.EventSource = MockEventSource
globalThis.scrollTo = vi.fn()
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('hides message.delta when agent.message exists in history', async () => {
localStorage.setItem('ars-token', 'token')
const router = makeRouter()
router.push('/chat/session-1')
await router.isReady()
const list = [
buildMessage({ seq: 1, role: 'USER', type: 'user.prompt', content: 'hi' }),
buildMessage({
seq: 2,
role: 'AGENT',
type: 'message.delta',
content: 'partial',
payload: { run_id: 'run-1' },
dedupe_key: 'run-1:delta:1',
}),
buildMessage({
seq: 3,
role: 'AGENT',
type: 'agent.message',
content: 'final answer',
payload: { run_id: 'run-1' },
dedupe_key: 'run-1:final:1',
}),
]
mockedAxios.get.mockImplementation((url) => {
if (url.endsWith('/sessions/session-1')) {
return Promise.resolve({
data: { session_name: 'Demo', status: 'OPEN', last_seq: 3 },
})
}
if (url.endsWith('/sessions/session-1/messages')) {
return Promise.resolve({ data: { data: list } })
}
return Promise.resolve({ data: {} })
})
const wrapper = mount(ChatView, {
global: {
plugins: [router, ElementPlus],
},
})
await flushPromises()
await nextTick()
const bubbles = wrapper.findAll('.bubble')
expect(bubbles).toHaveLength(2)
const texts = wrapper.findAll('.bubble-text').map((node) => node.text())
expect(texts).toEqual(['hi', 'final answer'])
})
it('appends message.delta content and replaces with agent.message on sse', async () => {
localStorage.setItem('ars-token', 'token')
const router = makeRouter()
router.push('/chat/session-1')
await router.isReady()
mockedAxios.get.mockImplementation((url) => {
if (url.endsWith('/sessions/session-1')) {
return Promise.resolve({
data: { session_name: 'Demo', status: 'OPEN', last_seq: 0 },
})
}
if (url.endsWith('/sessions/session-1/messages')) {
return Promise.resolve({ data: { data: [] } })
}
return Promise.resolve({ data: {} })
})
const wrapper = mount(ChatView, {
global: {
plugins: [router, ElementPlus],
},
})
await flushPromises()
await nextTick()
latestEventSource.onmessage({
data: JSON.stringify(
buildMessage({
seq: 1,
role: 'AGENT',
type: 'message.delta',
content: 'hello',
payload: { run_id: 'run-1' },
dedupe_key: 'run-1:delta:1',
})
),
})
await flushPromises()
await nextTick()
expect(wrapper.findAll('.bubble')).toHaveLength(1)
expect(wrapper.find('.bubble-text').text()).not.toBe('hello')
vi.runAllTimers()
await nextTick()
let bubbles = wrapper.findAll('.bubble-text')
expect(bubbles[bubbles.length - 1].text()).toBe('hello')
latestEventSource.onmessage({
data: JSON.stringify(
buildMessage({
seq: 2,
role: 'AGENT',
type: 'message.delta',
content: ' world',
payload: { run_id: 'run-1' },
dedupe_key: 'run-1:delta:2',
})
),
})
await flushPromises()
await nextTick()
bubbles = wrapper.findAll('.bubble-text')
expect(bubbles[bubbles.length - 1].text()).toBe('hello')
vi.runAllTimers()
await nextTick()
expect(wrapper.find('.bubble-text').text()).toBe('hello world')
latestEventSource.onmessage({
data: JSON.stringify(
buildMessage({
seq: 2,
role: 'AGENT',
type: 'message.delta',
content: '!!!',
payload: { run_id: 'run-1' },
dedupe_key: 'run-1:delta:2',
})
),
})
await flushPromises()
await nextTick()
expect(wrapper.find('.bubble-text').text()).toBe('hello world')
latestEventSource.onmessage({
data: JSON.stringify(
buildMessage({
seq: 3,
role: 'AGENT',
type: 'agent.message',
content: 'final',
payload: { run_id: 'run-1' },
dedupe_key: 'run-1:final:1',
})
),
})
await flushPromises()
await nextTick()
expect(wrapper.findAll('.bubble')).toHaveLength(1)
expect(wrapper.find('.bubble-text').text()).toBe('final')
})
})
describe('ChatView system error and failed status handling', () => {
beforeEach(() => {
localStorage.clear()
vi.clearAllMocks()
latestEventSource = null
globalThis.EventSource = MockEventSource
globalThis.scrollTo = vi.fn()
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('shows system error with payload toggle', async () => {
localStorage.setItem('ars-token', 'token')
const router = makeRouter()
router.push('/chat/session-1')
await router.isReady()
const list = [
buildMessage({
seq: 1,
role: 'SYSTEM',
type: 'error',
content: 'HTTP_ERROR',
payload: {
run_id: 'run-err',
message: 'Agent provider failed',
raw_message: '{"error":"oops"}',
},
dedupe_key: 'run-err:error',
}),
]
mockedAxios.get.mockImplementation((url) => {
if (url.endsWith('/sessions/session-1')) {
return Promise.resolve({
data: { session_name: 'Demo', status: 'OPEN', last_seq: 1 },
})
}
if (url.endsWith('/sessions/session-1/messages')) {
return Promise.resolve({ data: { data: list } })
}
return Promise.resolve({ data: {} })
})
const wrapper = mount(ChatView, {
global: {
plugins: [router, ElementPlus],
},
})
await flushPromises()
await nextTick()
const bubble = wrapper.find('.bubble-system')
expect(bubble.exists()).toBe(true)
expect(bubble.text()).toContain('Agent provider failed')
const toggle = wrapper.find('.payload-toggle')
expect(toggle.exists()).toBe(true)
await toggle.trigger('click')
await nextTick()
expect(wrapper.find('.payload-json').text()).toContain('raw_message')
})
it('unlocks and finalizes on FAILED run.status', async () => {
localStorage.setItem('ars-token', 'token')
const router = makeRouter()
router.push('/chat/session-1')
await router.isReady()
const list = [
buildMessage({ seq: 1, role: 'USER', type: 'user.prompt', content: 'hi' }),
buildMessage({
seq: 2,
role: 'SYSTEM',
type: 'run.status',
content: 'RUNNING',
payload: { status: 'RUNNING', run_id: 'run-1' },
}),
]
mockedAxios.get.mockImplementation((url) => {
if (url.endsWith('/sessions/session-1')) {
return Promise.resolve({
data: { session_name: 'Demo', status: 'OPEN', last_seq: 2 },
})
}
if (url.endsWith('/sessions/session-1/messages')) {
return Promise.resolve({ data: { data: list } })
}
return Promise.resolve({ data: {} })
})
const wrapper = mount(ChatView, {
global: {
plugins: [router, ElementPlus],
},
})
await flushPromises()
await nextTick()
const sendButton = wrapper.find('button.send-btn')
expect(sendButton.element.disabled).toBe(true)
latestEventSource.onmessage({
data: JSON.stringify(
buildMessage({
seq: 3,
role: 'AGENT',
type: 'message.delta',
content: 'hello',
payload: { run_id: 'run-1' },
dedupe_key: 'run-1:delta:1',
})
),
})
await flushPromises()
vi.runAllTimers()
await nextTick()
const getLastBubbleText = () => {
const bubbles = wrapper.findAll('.bubble-text')
return bubbles[bubbles.length - 1]?.text()
}
expect(getLastBubbleText()).toBe('hello')
latestEventSource.onmessage({
data: JSON.stringify(
buildMessage({
seq: 4,
role: 'SYSTEM',
type: 'run.status',
content: null,
payload: { status: 'FAILED', run_id: 'run-1' },
dedupe_key: 'run-1:failed',
})
),
})
await flushPromises()
await nextTick()
expect(sendButton.element.disabled).toBe(false)
latestEventSource.onmessage({
data: JSON.stringify(
buildMessage({
seq: 5,
role: 'AGENT',
type: 'message.delta',
content: ' more',
payload: { run_id: 'run-1' },
dedupe_key: 'run-1:delta:2',
})
),
})
await flushPromises()
vi.runAllTimers()
await nextTick()
expect(getLastBubbleText()).toBe('hello')
})
})