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

@@ -45,6 +45,45 @@ const summary = reactive({
}) })
const messagesState = ref([]) const messagesState = ref([])
const seenDedupeKeys = new Set()
const finalizedRunIds = new Set()
// Typewriter effect state for streaming deltas
const typingStates = new Map()
const typingIntervalMs = 24
const getRunId = (msg = {}) => msg.payload?.run_id || null
const shouldGroupByRun = (msg = {}) => {
const runId = getRunId(msg)
return Boolean(
runId &&
(msg.type === 'message.delta' ||
msg.type === 'agent.message' ||
msg.type === 'error')
)
}
const markDedupeKey = (msg = {}) => {
const key = msg.dedupe_key
if (!key) return false
if (seenDedupeKeys.has(key)) return true
seenDedupeKeys.add(key)
return false
}
const stopTyping = (messageId) => {
const state = typingStates.get(messageId)
if (state?.timer) {
clearInterval(state.timer)
}
typingStates.delete(messageId)
}
const stopAllTyping = () => {
Array.from(typingStates.keys()).forEach((messageId) => {
stopTyping(messageId)
})
}
const resetMessageCache = () => {
seenDedupeKeys.clear()
finalizedRunIds.clear()
stopAllTyping()
}
const normalizeTime = (val) => const normalizeTime = (val) =>
val val
? new Date(val).toLocaleTimeString('zh-CN', { ? new Date(val).toLocaleTimeString('zh-CN', {
@@ -54,15 +93,36 @@ const normalizeTime = (val) =>
}) })
: '--:--' : '--:--'
const normalizeMessage = (msg = {}) => { const buildMessageId = (msg = {}, seq = 0, runId = null) =>
runId
? `run-${runId}`
: msg.dedupe_key || msg.message_id || `msg-${seq || Math.random()}`
const normalizeMessage = (msg = {}, options = {}) => {
const seq = msg.seq ?? lastSeq.value ?? 0 const seq = msg.seq ?? lastSeq.value ?? 0
const runId =
options.runId ?? (options.groupByRun ? getRunId(msg) : null)
const statusText =
msg.type === 'error'
? msg.payload?.message ||
msg.payload?.error ||
msg.content ||
'系统错误'
: msg.content
const text =
options.text ?? statusText ?? (options.allowEmpty ? '' : '(空内容)')
const roleUpper = msg.role?.toUpperCase()
const normalizedRole =
roleUpper === 'USER' ? 'user' : roleUpper === 'SYSTEM' ? 'system' : 'agent'
const author =
roleUpper === 'USER' ? '你' : roleUpper === 'SYSTEM' ? '系统' : 'ARS'
return { return {
id: msg.message_id || `msg-${seq || Math.random()}`, id: options.id || buildMessageId(msg, seq, runId),
seq, seq,
role: msg.role?.toLowerCase() === 'user' ? 'user' : 'agent', role: normalizedRole,
author: msg.role?.toUpperCase() === 'USER' ? '你' : 'ARS', author,
time: normalizeTime(msg.created_at), time: normalizeTime(msg.created_at),
text: msg.content || '(空内容)', text,
attachments: attachments:
msg.payload?.attachments?.map((file) => ({ msg.payload?.attachments?.map((file) => ({
name: file.name || '附件', name: file.name || '附件',
@@ -70,12 +130,18 @@ const normalizeMessage = (msg = {}) => {
size: file.size || '', size: file.size || '',
url: file.url || sampleImage, url: file.url || sampleImage,
})) || [], })) || [],
type: msg.type,
runId,
dedupeKey: msg.dedupe_key || null,
payload: msg.payload || null,
} }
} }
const extractRunStatus = (msg = {}) => { const extractRunStatus = (msg = {}) => {
const status = msg.payload?.status || msg.content const status = msg.payload?.status || msg.content
return status === 'RUNNING' || status === 'DONE' ? status : null return status === 'RUNNING' || status === 'DONE' || status === 'FAILED'
? status
: null
} }
const applyRunStatus = (msg = {}) => { const applyRunStatus = (msg = {}) => {
@@ -83,6 +149,11 @@ const applyRunStatus = (msg = {}) => {
const status = extractRunStatus(msg) const status = extractRunStatus(msg)
if (!status) return false if (!status) return false
runStatus.value = status runStatus.value = status
const runId = getRunId(msg)
if (status === 'FAILED' && runId) {
finalizedRunIds.add(runId)
stopTyping(`run-${runId}`)
}
if (typeof msg.seq === 'number') { if (typeof msg.seq === 'number') {
lastSeq.value = Math.max(lastSeq.value || 0, msg.seq) lastSeq.value = Math.max(lastSeq.value || 0, msg.seq)
summary.lastSeq = lastSeq.value summary.lastSeq = lastSeq.value
@@ -99,16 +170,75 @@ const refreshLastSeq = (list = []) => {
summary.lastSeq = maxSeq summary.lastSeq = maxSeq
} }
const appendDeltaToList = (normalized, runIndexMap, msg) => {
const runId = getRunId(msg)
const deltaText = msg.content || ''
const index =
runId && runIndexMap.has(runId) ? runIndexMap.get(runId) : -1
if (index >= 0) {
const current = normalized[index]
const existingText = current.text === '(空内容)' ? '' : current.text
normalized[index] = {
...current,
text: `${existingText}${deltaText}`,
type: msg.type || current.type,
dedupeKey: msg.dedupe_key || current.dedupeKey,
seq: msg.seq ?? current.seq,
}
return
}
const base = normalizeMessage(msg, {
groupByRun: shouldGroupByRun(msg),
allowEmpty: true,
text: deltaText,
})
normalized.push(base)
if (base.runId) {
runIndexMap.set(base.runId, normalized.length - 1)
}
}
const normalizeMessageList = (list = []) => { const normalizeMessageList = (list = []) => {
let latestStatus = runStatus.value let latestStatus = runStatus.value
const normalized = [] const normalized = []
const runIndexMap = new Map()
resetMessageCache()
list.forEach((msg) => { list.forEach((msg) => {
if (msg?.type === 'agent.message') {
const runId = getRunId(msg)
if (runId) finalizedRunIds.add(runId)
}
if (msg?.type === 'run.status') { if (msg?.type === 'run.status') {
const status = extractRunStatus(msg)
if (status === 'FAILED') {
const runId = getRunId(msg)
if (runId) finalizedRunIds.add(runId)
}
}
})
list.forEach((msg) => {
if (!msg) return
if (msg.type === 'run.status') {
const status = extractRunStatus(msg) const status = extractRunStatus(msg)
if (status) latestStatus = status if (status) latestStatus = status
return return
} }
normalized.push(normalizeMessage(msg)) if (markDedupeKey(msg)) return
const runId = getRunId(msg)
if (msg.type === 'message.delta') {
if (runId && finalizedRunIds.has(runId)) return
appendDeltaToList(normalized, runIndexMap, msg)
return
}
const normalizedMsg = normalizeMessage(msg, {
groupByRun: shouldGroupByRun(msg),
})
normalized.push(normalizedMsg)
if (normalizedMsg.runId) {
runIndexMap.set(normalizedMsg.runId, normalized.length - 1)
}
}) })
runStatus.value = latestStatus runStatus.value = latestStatus
refreshLastSeq(list) refreshLastSeq(list)
@@ -141,6 +271,105 @@ const upsertMessage = (msg) => {
}) })
} }
const updateMessageText = (messageId, text) => {
const list = messagesState.value
const idx = list.findIndex((item) => item.id === messageId)
if (idx < 0) return
const next = [...list]
next[idx] = { ...next[idx], text }
messagesState.value = next
}
const startTyping = (messageId, targetText) => {
if (!messageId) return
if (typeof window === 'undefined') {
updateMessageText(messageId, targetText)
return
}
const state = typingStates.get(messageId) || { targetText: '', timer: null }
state.targetText = targetText
typingStates.set(messageId, state)
if (state.timer) return
state.timer = window.setInterval(() => {
const current = messagesState.value.find((item) => item.id === messageId)
if (!current) {
stopTyping(messageId)
return
}
const currentText = current.text || ''
if (state.targetText.length <= currentText.length) {
if (currentText !== state.targetText) {
updateMessageText(messageId, state.targetText)
}
stopTyping(messageId)
return
}
const remaining = state.targetText.length - currentText.length
const step = Math.max(1, Math.min(8, Math.ceil(remaining / 60)))
const nextText = state.targetText.slice(
0,
Math.min(state.targetText.length, currentText.length + step)
)
updateMessageText(messageId, nextText)
}, typingIntervalMs)
}
const appendDeltaMessage = (msg) => {
const runId = getRunId(msg)
if (runId && finalizedRunIds.has(runId)) return
const messageId = runId ? `run-${runId}` : buildMessageId(msg, msg.seq)
const existing =
runId && messagesState.value.length
? messagesState.value.find((item) => item.runId === runId)
: null
const existingText =
existing && existing.text && existing.text !== '(空内容)'
? existing.text
: ''
const pendingState = typingStates.get(messageId)
const baseText = pendingState?.targetText ?? existingText
const deltaText = msg.content || ''
const nextText = `${baseText}${deltaText}`
const normalized = normalizeMessage(msg, {
id: messageId,
groupByRun: shouldGroupByRun(msg),
allowEmpty: true,
text: existingText,
})
if (existing?.attachments?.length && !normalized.attachments?.length) {
normalized.attachments = existing.attachments
}
upsertMessage(normalized)
if (nextText !== existingText) {
startTyping(normalized.id, nextText)
}
}
const handleIncomingMessage = (msg) => {
if (!msg) return
if (applyRunStatus(msg)) return
if (markDedupeKey(msg)) return
if (msg.type === 'message.delta') {
appendDeltaMessage(msg)
return
}
if (msg.type === 'agent.message') {
const runId = getRunId(msg)
if (runId) {
finalizedRunIds.add(runId)
stopTyping(`run-${runId}`)
}
}
if (msg.type === 'error') {
const runId = getRunId(msg)
if (runId) finalizedRunIds.add(runId)
}
const normalized = normalizeMessage(msg, {
groupByRun: shouldGroupByRun(msg),
})
upsertMessage(normalized)
}
const normalizedSummary = (data = {}) => ({ const normalizedSummary = (data = {}) => ({
title: data.session_name || '未命名会话', title: data.session_name || '未命名会话',
status: data.status || 'OPEN', status: data.status || 'OPEN',
@@ -153,6 +382,26 @@ const runActive = computed(() => runStatus.value === 'RUNNING')
const inputLocked = computed(() => summary.status === 'CLOSED') const inputLocked = computed(() => summary.status === 'CLOSED')
const messages = computed(() => messagesState.value || []) const messages = computed(() => messagesState.value || [])
const formatPayload = (payload) => {
try {
return JSON.stringify(payload ?? {}, null, 2)
} catch (e) {
return String(payload ?? '')
}
}
const togglePayload = (id) => {
const list = messagesState.value
const idx = list.findIndex((item) => item.id === id)
if (idx < 0) return
const next = [...list]
next[idx] = {
...next[idx],
showPayload: !next[idx].showPayload,
}
messagesState.value = next
}
const handleFileChange = (uploadFile, uploadFilesList) => { const handleFileChange = (uploadFile, uploadFilesList) => {
fileList.value = uploadFilesList fileList.value = uploadFilesList
uploadFiles.value = uploadFilesList.map((file) => ({ uploadFiles.value = uploadFilesList.map((file) => ({
@@ -387,9 +636,7 @@ const fetchSingleMessage = async (messageId) => {
{ headers: { Authorization: `Bearer ${token}` } } { headers: { Authorization: `Bearer ${token}` } }
) )
const detail = data.data || data const detail = data.data || data
if (applyRunStatus(detail)) return handleIncomingMessage(detail)
const normalized = normalizeMessage(detail)
upsertMessage(normalized)
} catch (err) { } catch (err) {
if (err.response?.status === 401) router.replace({ name: 'login' }) if (err.response?.status === 401) router.replace({ name: 'login' })
} }
@@ -464,13 +711,11 @@ const startSse = () => {
try { try {
const data = JSON.parse(event.data || '{}') const data = JSON.parse(event.data || '{}')
if (!data) return if (!data) return
if (!data.content && data.message_id) { if (!data.content && data.message_id && !data.type) {
fetchSingleMessage(data.message_id) fetchSingleMessage(data.message_id)
return return
} }
if (applyRunStatus(data)) return handleIncomingMessage(data)
const normalized = normalizeMessage(data)
upsertMessage(normalized)
nextTick(() => scrollToBottom()) nextTick(() => scrollToBottom())
} catch (e) { } catch (e) {
// ignore parse error // ignore parse error
@@ -489,6 +734,7 @@ const startSse = () => {
const applySession = () => { const applySession = () => {
if (!sessionId.value) return if (!sessionId.value) return
stopSse() stopSse()
resetMessageCache()
lastSeq.value = 0 lastSeq.value = 0
runStatus.value = 'DONE' runStatus.value = 'DONE'
localStorage.setItem('ars-current-session', sessionId.value) localStorage.setItem('ars-current-session', sessionId.value)
@@ -520,6 +766,7 @@ onMounted(() => {
onBeforeUnmount(() => { onBeforeUnmount(() => {
window.removeEventListener('scroll', handleWindowScroll) window.removeEventListener('scroll', handleWindowScroll)
stopAllTyping()
stopSse() stopSse()
}) })
</script> </script>
@@ -588,11 +835,23 @@ onBeforeUnmount(() => {
v-for="msg in messages" v-for="msg in messages"
:key="msg.id" :key="msg.id"
class="bubble" class="bubble"
:class="msg.role === 'user' ? 'bubble-user' : 'bubble-agent'" :class="[
msg.role === 'user'
? 'bubble-user'
: msg.role === 'system'
? 'bubble-system'
: 'bubble-agent',
]"
> >
<div class="bubble-head"> <div class="bubble-head">
<span class="author">{{ msg.author }}</span> <span class="author">{{ msg.author }}</span>
<span class="time">{{ msg.time }}</span> <span class="time">{{ msg.time }}</span>
<template v-if="msg.type === 'error'">
<el-tag size="small" type="danger" effect="plain">
系统错误
</el-tag>
</template>
<template v-else>
<el-tag <el-tag
v-if="msg.role === 'agent'" v-if="msg.role === 'agent'"
size="small" size="small"
@@ -601,6 +860,14 @@ onBeforeUnmount(() => {
> >
ARS 回复 ARS 回复
</el-tag> </el-tag>
<el-tag
v-else-if="msg.role === 'system'"
size="small"
type="warning"
effect="plain"
>
系统
</el-tag>
<el-tag <el-tag
v-else v-else
size="small" size="small"
@@ -609,8 +876,24 @@ onBeforeUnmount(() => {
> >
提示词 提示词
</el-tag> </el-tag>
</template>
</div> </div>
<template v-if="msg.type === 'error'">
<div class="error-card">
<p class="bubble-text error-title">
{{ msg.text || '系统错误' }}
</p>
<button type="button" class="payload-toggle" @click="togglePayload(msg.id)">
{{ msg.showPayload ? '收起详情' : '查看详情' }}
</button>
<pre v-if="msg.showPayload" class="payload-json">
{{ formatPayload(msg.payload) }}
</pre>
</div>
</template>
<template v-else>
<p class="bubble-text">{{ msg.text }}</p> <p class="bubble-text">{{ msg.text }}</p>
</template>
<div v-if="msg.attachments?.length" class="attachment-list"> <div v-if="msg.attachments?.length" class="attachment-list">
<div <div
v-for="file in msg.attachments" v-for="file in msg.attachments"
@@ -885,6 +1168,10 @@ onBeforeUnmount(() => {
.bubble-agent { .bubble-agent {
border-color: rgba(0, 0, 0, 0.04); border-color: rgba(0, 0, 0, 0.04);
} }
.bubble-system {
border-color: rgba(239, 68, 68, 0.18);
background: linear-gradient(135deg, #fff1f2, #ffe4e6);
}
.bubble-head { .bubble-head {
display: flex; display: flex;
@@ -909,6 +1196,48 @@ onBeforeUnmount(() => {
line-height: 1.7; line-height: 1.7;
font-size: 15px; font-size: 15px;
} }
.error-card {
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px 12px;
border-radius: 12px;
background: linear-gradient(135deg, rgba(239, 68, 68, 0.12), rgba(248, 113, 113, 0.1));
border: 1px solid rgba(239, 68, 68, 0.22);
box-shadow: 0 8px 16px rgba(239, 68, 68, 0.12);
}
.error-title {
color: #b91c1c;
font-weight: 700;
}
.payload-toggle {
align-self: flex-start;
padding: 6px 10px;
border-radius: 10px;
border: 1px solid rgba(185, 28, 28, 0.4);
background: #fff5f5;
color: #b91c1c;
cursor: pointer;
transition: all 0.12s ease;
font-weight: 600;
}
.payload-toggle:hover {
background: #fee2e2;
transform: translateY(-1px);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.08);
}
.payload-json {
margin: 0;
padding: 12px;
border-radius: 10px;
background: #0f172a;
color: #e2e8f0;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 13px;
overflow: auto;
max-height: 240px;
white-space: pre;
}
.attachment-list { .attachment-list {
display: flex; display: flex;

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 { mount, flushPromises } from '@vue/test-utils'
import { createRouter, createMemoryHistory } from 'vue-router' import { createRouter, createMemoryHistory } from 'vue-router'
import ElementPlus from 'element-plus' import ElementPlus from 'element-plus'
@@ -265,3 +265,362 @@ describe('ChatView run.status handling', () => {
expect(router.currentRoute.value.name).toBe('login') 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')
})
})