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.text }}
+ ++ {{ msg.text || '系统错误' }} +
+ +
+{{ formatPayload(msg.payload) }}
+
+ {{ msg.text }}
+