diff --git a/src/views/ChatView.vue b/src/views/ChatView.vue index f129372..7fc9fb6 100644 --- a/src/views/ChatView.vue +++ b/src/views/ChatView.vue @@ -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() }) @@ -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', + ]" >
{{ msg.author }} {{ msg.time }} - - ARS 回复 - - - 提示词 - + +
-

{{ msg.text }}

+ +
{ .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; diff --git a/tests/chat-view.spec.js b/tests/chat-view.spec.js index 13e0a59..b514388 100644 --- a/tests/chat-view.spec.js +++ b/tests/chat-view.spec.js @@ -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') + }) +})