main: 增强 ChatView 消息处理与错误展示功能
- 调整消息归一化逻辑,添加对 delta 和 agent.message 的支持 - 实现长文本逐字显示效果,增强用户交互体验 - 新增系统错误展示机制,支持查看原始错误信息 - 改善状态更新逻辑,优化 FAILED 状态的处理 - 优化界面样式,增加系统消息气泡与错误卡片设计
This commit is contained in:
@@ -45,6 +45,45 @@ const summary = reactive({
|
||||
})
|
||||
|
||||
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) =>
|
||||
val
|
||||
? 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 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 {
|
||||
id: msg.message_id || `msg-${seq || Math.random()}`,
|
||||
id: options.id || buildMessageId(msg, seq, runId),
|
||||
seq,
|
||||
role: msg.role?.toLowerCase() === 'user' ? 'user' : 'agent',
|
||||
author: msg.role?.toUpperCase() === 'USER' ? '你' : 'ARS',
|
||||
role: normalizedRole,
|
||||
author,
|
||||
time: normalizeTime(msg.created_at),
|
||||
text: msg.content || '(空内容)',
|
||||
text,
|
||||
attachments:
|
||||
msg.payload?.attachments?.map((file) => ({
|
||||
name: file.name || '附件',
|
||||
@@ -70,12 +130,18 @@ const normalizeMessage = (msg = {}) => {
|
||||
size: file.size || '',
|
||||
url: file.url || sampleImage,
|
||||
})) || [],
|
||||
type: msg.type,
|
||||
runId,
|
||||
dedupeKey: msg.dedupe_key || null,
|
||||
payload: msg.payload || null,
|
||||
}
|
||||
}
|
||||
|
||||
const extractRunStatus = (msg = {}) => {
|
||||
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 = {}) => {
|
||||
@@ -83,6 +149,11 @@ const applyRunStatus = (msg = {}) => {
|
||||
const status = extractRunStatus(msg)
|
||||
if (!status) return false
|
||||
runStatus.value = status
|
||||
const runId = getRunId(msg)
|
||||
if (status === 'FAILED' && runId) {
|
||||
finalizedRunIds.add(runId)
|
||||
stopTyping(`run-${runId}`)
|
||||
}
|
||||
if (typeof msg.seq === 'number') {
|
||||
lastSeq.value = Math.max(lastSeq.value || 0, msg.seq)
|
||||
summary.lastSeq = lastSeq.value
|
||||
@@ -99,16 +170,75 @@ const refreshLastSeq = (list = []) => {
|
||||
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 = []) => {
|
||||
let latestStatus = runStatus.value
|
||||
const normalized = []
|
||||
const runIndexMap = new Map()
|
||||
resetMessageCache()
|
||||
|
||||
list.forEach((msg) => {
|
||||
if (msg?.type === 'agent.message') {
|
||||
const runId = getRunId(msg)
|
||||
if (runId) finalizedRunIds.add(runId)
|
||||
}
|
||||
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)
|
||||
if (status) latestStatus = status
|
||||
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
|
||||
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 = {}) => ({
|
||||
title: data.session_name || '未命名会话',
|
||||
status: data.status || 'OPEN',
|
||||
@@ -153,6 +382,26 @@ const runActive = computed(() => runStatus.value === 'RUNNING')
|
||||
const inputLocked = computed(() => summary.status === 'CLOSED')
|
||||
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) => {
|
||||
fileList.value = uploadFilesList
|
||||
uploadFiles.value = uploadFilesList.map((file) => ({
|
||||
@@ -387,9 +636,7 @@ const fetchSingleMessage = async (messageId) => {
|
||||
{ headers: { Authorization: `Bearer ${token}` } }
|
||||
)
|
||||
const detail = data.data || data
|
||||
if (applyRunStatus(detail)) return
|
||||
const normalized = normalizeMessage(detail)
|
||||
upsertMessage(normalized)
|
||||
handleIncomingMessage(detail)
|
||||
} catch (err) {
|
||||
if (err.response?.status === 401) router.replace({ name: 'login' })
|
||||
}
|
||||
@@ -464,13 +711,11 @@ const startSse = () => {
|
||||
try {
|
||||
const data = JSON.parse(event.data || '{}')
|
||||
if (!data) return
|
||||
if (!data.content && data.message_id) {
|
||||
if (!data.content && data.message_id && !data.type) {
|
||||
fetchSingleMessage(data.message_id)
|
||||
return
|
||||
}
|
||||
if (applyRunStatus(data)) return
|
||||
const normalized = normalizeMessage(data)
|
||||
upsertMessage(normalized)
|
||||
handleIncomingMessage(data)
|
||||
nextTick(() => scrollToBottom())
|
||||
} catch (e) {
|
||||
// ignore parse error
|
||||
@@ -489,6 +734,7 @@ const startSse = () => {
|
||||
const applySession = () => {
|
||||
if (!sessionId.value) return
|
||||
stopSse()
|
||||
resetMessageCache()
|
||||
lastSeq.value = 0
|
||||
runStatus.value = 'DONE'
|
||||
localStorage.setItem('ars-current-session', sessionId.value)
|
||||
@@ -520,6 +766,7 @@ onMounted(() => {
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('scroll', handleWindowScroll)
|
||||
stopAllTyping()
|
||||
stopSse()
|
||||
})
|
||||
</script>
|
||||
@@ -588,29 +835,65 @@ onBeforeUnmount(() => {
|
||||
v-for="msg in messages"
|
||||
:key="msg.id"
|
||||
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">
|
||||
<span class="author">{{ msg.author }}</span>
|
||||
<span class="time">{{ msg.time }}</span>
|
||||
<el-tag
|
||||
v-if="msg.role === 'agent'"
|
||||
size="small"
|
||||
type="info"
|
||||
effect="plain"
|
||||
>
|
||||
ARS 回复
|
||||
</el-tag>
|
||||
<el-tag
|
||||
v-else
|
||||
size="small"
|
||||
type="primary"
|
||||
effect="plain"
|
||||
>
|
||||
提示词
|
||||
</el-tag>
|
||||
<template v-if="msg.type === 'error'">
|
||||
<el-tag size="small" type="danger" effect="plain">
|
||||
系统错误
|
||||
</el-tag>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-tag
|
||||
v-if="msg.role === 'agent'"
|
||||
size="small"
|
||||
type="info"
|
||||
effect="plain"
|
||||
>
|
||||
ARS 回复
|
||||
</el-tag>
|
||||
<el-tag
|
||||
v-else-if="msg.role === 'system'"
|
||||
size="small"
|
||||
type="warning"
|
||||
effect="plain"
|
||||
>
|
||||
系统
|
||||
</el-tag>
|
||||
<el-tag
|
||||
v-else
|
||||
size="small"
|
||||
type="primary"
|
||||
effect="plain"
|
||||
>
|
||||
提示词
|
||||
</el-tag>
|
||||
</template>
|
||||
</div>
|
||||
<p class="bubble-text">{{ msg.text }}</p>
|
||||
<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>
|
||||
</template>
|
||||
<div v-if="msg.attachments?.length" class="attachment-list">
|
||||
<div
|
||||
v-for="file in msg.attachments"
|
||||
@@ -885,6 +1168,10 @@ onBeforeUnmount(() => {
|
||||
.bubble-agent {
|
||||
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 {
|
||||
display: flex;
|
||||
@@ -909,6 +1196,48 @@ onBeforeUnmount(() => {
|
||||
line-height: 1.7;
|
||||
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 {
|
||||
display: flex;
|
||||
|
||||
Reference in New Issue
Block a user