main: 增强 ChatView 消息处理与错误展示功能
- 调整消息归一化逻辑,添加对 delta 和 agent.message 的支持 - 实现长文本逐字显示效果,增强用户交互体验 - 新增系统错误展示机制,支持查看原始错误信息 - 改善状态更新逻辑,优化 FAILED 状态的处理 - 优化界面样式,增加系统消息气泡与错误卡片设计
This commit is contained in:
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user