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