From afb166dddf63f63dde73f97bd19d3d68a97d5f5e Mon Sep 17 00:00:00 2001 From: Roog Date: Thu, 18 Dec 2025 17:50:02 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=20ChatView=20=E9=A1=B5?= =?UTF-8?q?=E9=9D=A2=E5=8A=9F=E8=83=BD=E4=B8=8E=E7=94=A8=E6=88=B7=E4=BA=A4?= =?UTF-8?q?=E4=BA=92=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 增加请求状态管理,区分 RUNNING 和 DONE 状态 - 优化消息列表归一化处理逻辑,提高代码可读性 - 添加按键事件支持 (Ctrl+Enter) 以改善发送消息体验 - 更新输入框与发送按钮的禁用逻辑,明确状态反馈 - 提升 SSE 连接恢复的安全性,增加鉴权检查 - 优化界面显示,新增快捷键提示及样式改进 - 移除 package-lock.json 中多余的 peer 标记 --- package-lock.json | 14 +-- src/views/ChatView.vue | 145 ++++++++++++++++++++-- src/views/LoginView.vue | 8 +- tests/chat-view.spec.js | 267 ++++++++++++++++++++++++++++++++++++++++ tests/login.spec.js | 62 ++++++++++ 5 files changed, 470 insertions(+), 26 deletions(-) create mode 100644 tests/chat-view.spec.js create mode 100644 tests/login.spec.js diff --git a/package-lock.json b/package-lock.json index e9ed8e7..51108cc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -197,7 +197,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -244,7 +243,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -1145,7 +1143,6 @@ "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz", "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", "license": "MIT", - "peer": true, "dependencies": { "@types/lodash": "*" } @@ -2338,7 +2335,6 @@ "integrity": "sha512-GtldT42B8+jefDUC4yUKAvsaOrH7PDHmZxZXNgF2xMmymjUbRYJvpAybZAKEmXDGTM0mCsz8duOa4vTm5AY2Kg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@acemir/cssom": "^0.9.28", "@asamuzakjp/dom-selector": "^6.7.6", @@ -2377,15 +2373,13 @@ "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/lodash-es": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/lodash-unified": { "version": "1.0.3", @@ -2630,7 +2624,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -2657,7 +2650,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -3048,7 +3040,6 @@ "integrity": "sha512-ITcnkFeR3+fI8P1wMgItjGrR10170d8auB4EpMLPqmx6uxElH3a/hHGQabSHKdqd4FXWO1nFIp9rRn7JQ34ACQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -3201,7 +3192,6 @@ "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.25.tgz", "integrity": "sha512-YLVdgv2K13WJ6n+kD5owehKtEXwdwXuj2TTyJMsO7pSeKw2bfRNZGjhB7YzrpbMYj5b5QsUebHpOqR3R3ziy/g==", "license": "MIT", - "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.25", "@vue/compiler-sfc": "3.5.25", diff --git a/src/views/ChatView.vue b/src/views/ChatView.vue index 71c6f79..f129372 100644 --- a/src/views/ChatView.vue +++ b/src/views/ChatView.vue @@ -19,6 +19,7 @@ const inputText = ref('') const uploadFiles = ref([]) const fileList = ref([]) const sending = ref(false) +const runStatus = ref('DONE') const loadingMessages = ref(false) const errorMessage = ref('') const showJumpDown = ref(false) @@ -72,6 +73,48 @@ const normalizeMessage = (msg = {}) => { } } +const extractRunStatus = (msg = {}) => { + const status = msg.payload?.status || msg.content + return status === 'RUNNING' || status === 'DONE' ? status : null +} + +const applyRunStatus = (msg = {}) => { + if (msg.type !== 'run.status') return false + const status = extractRunStatus(msg) + if (!status) return false + runStatus.value = status + if (typeof msg.seq === 'number') { + lastSeq.value = Math.max(lastSeq.value || 0, msg.seq) + summary.lastSeq = lastSeq.value + } + return true +} + +const refreshLastSeq = (list = []) => { + const maxSeq = list.reduce((max, item) => { + const seq = typeof item.seq === 'number' ? item.seq : 0 + return seq > max ? seq : max + }, lastSeq.value || 0) + lastSeq.value = maxSeq + summary.lastSeq = maxSeq +} + +const normalizeMessageList = (list = []) => { + let latestStatus = runStatus.value + const normalized = [] + list.forEach((msg) => { + if (msg?.type === 'run.status') { + const status = extractRunStatus(msg) + if (status) latestStatus = status + return + } + normalized.push(normalizeMessage(msg)) + }) + runStatus.value = latestStatus + refreshLastSeq(list) + return normalized +} + const upsertMessage = (msg) => { const list = messagesState.value const idx = list.findIndex( @@ -106,6 +149,8 @@ const normalizedSummary = (data = {}) => ({ tags: data.last_message_type ? [data.last_message_type] : [], }) +const runActive = computed(() => runStatus.value === 'RUNNING') +const inputLocked = computed(() => summary.status === 'CLOSED') const messages = computed(() => messagesState.value || []) const handleFileChange = (uploadFile, uploadFilesList) => { @@ -142,8 +187,19 @@ const handleFileRemove = (file, list) => { uploadFiles.value = filtered.map(toAttachment) } +const handleComposerKeydown = (event) => { + if (event.key === 'Enter' && event.ctrlKey) { + event.preventDefault() + handleSend() + } +} + const handleSend = () => { if (sending.value) return + if (runActive.value) { + ElMessage.info('Agent 正在处理中,请稍候再试') + return + } if (summary.status === 'CLOSED') { ElMessage.warning('会话已归档,无法发送') return @@ -291,7 +347,7 @@ const fetchMessages = async () => { } ) const list = data.data || [] - messagesState.value = list.map((msg) => normalizeMessage(msg)) + messagesState.value = normalizeMessageList(list) if (list.length) { const last = list[list.length - 1] lastSeq.value = last.seq || 0 @@ -331,6 +387,7 @@ const fetchSingleMessage = async (messageId) => { { headers: { Authorization: `Bearer ${token}` } } ) const detail = data.data || data + if (applyRunStatus(detail)) return const normalized = normalizeMessage(detail) upsertMessage(normalized) } catch (err) { @@ -366,6 +423,26 @@ const stopSse = () => { } } +const checkSseAuth = async () => { + const token = localStorage.getItem('ars-token') || '' + if (!token) { + router.replace({ name: 'login' }) + return false + } + try { + await axios.get(`${apiBase}/me`, { + headers: { Authorization: `Bearer ${token}` }, + }) + return true + } catch (err) { + if (err.response?.status === 401) { + router.replace({ name: 'login' }) + return false + } + return true + } +} + const startSse = () => { if (typeof window === 'undefined' || typeof EventSource === 'undefined') return @@ -391,6 +468,7 @@ const startSse = () => { fetchSingleMessage(data.message_id) return } + if (applyRunStatus(data)) return const normalized = normalizeMessage(data) upsertMessage(normalized) nextTick(() => scrollToBottom()) @@ -400,7 +478,10 @@ const startSse = () => { } es.onerror = () => { stopSse() - sseRetryTimer.value = window.setTimeout(() => startSse(), 3000) + checkSseAuth().then((authed) => { + if (!authed) return + sseRetryTimer.value = window.setTimeout(() => startSse(), 3000) + }) } eventSource.value = es } @@ -409,6 +490,7 @@ const applySession = () => { if (!sessionId.value) return stopSse() lastSeq.value = 0 + runStatus.value = 'DONE' localStorage.setItem('ars-current-session', sessionId.value) fetchSummary() fetchMessages().then(() => { @@ -566,6 +648,7 @@ onBeforeUnmount(() => { multiple accept="image/*,.pdf,.txt,.doc,.docx,.md" :auto-upload="false" + :disabled="inputLocked" :show-file-list="false" :file-list="fileList" :on-change="handleFileChange" @@ -581,17 +664,24 @@ onBeforeUnmount(() => { :autosize="{ minRows: 3, maxRows: 8 }" class="composer-input" :placeholder="summary.status === 'CLOSED' ? '会话已归档,无法继续发送' : '输入消息,支持粘贴大段文本与附件...'" - :disabled="summary.status === 'CLOSED'" + :disabled="inputLocked" + @keydown="handleComposerKeydown" /> - - 发送 - +
+ + 发送 + + + Ctrl + + Enter 发送 + +
@@ -909,6 +999,32 @@ onBeforeUnmount(() => { padding: 0 18px; } +.send-wrap { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 6px; +} + +.send-hint { + font-size: 12px; + color: #9ca3af; + letter-spacing: 0.02em; +} + +.keycap { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 2px 6px; + border-radius: 8px; + border: 1px solid rgba(15, 23, 42, 0.12); + background: #ffffff; + color: #6b7280; + font-size: 11px; + font-weight: 600; +} + .pending-attachments { padding: 10px 0 0; border-top: 1px dashed rgba(0, 0, 0, 0.08); @@ -989,5 +1105,10 @@ onBeforeUnmount(() => { .send-btn { width: 100%; } + + .send-wrap { + width: 100%; + align-items: stretch; + } } diff --git a/src/views/LoginView.vue b/src/views/LoginView.vue index 82bdb44..cebfa4e 100644 --- a/src/views/LoginView.vue +++ b/src/views/LoginView.vue @@ -58,7 +58,11 @@ const handleLogin = async () => { - + { class="login-btn" :loading="loading" color="#1d4ed8" - @click="handleLogin" + native-type="submit" > 进入系统 diff --git a/tests/chat-view.spec.js b/tests/chat-view.spec.js new file mode 100644 index 0000000..13e0a59 --- /dev/null +++ b/tests/chat-view.spec.js @@ -0,0 +1,267 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' +import { createRouter, createMemoryHistory } from 'vue-router' +import ElementPlus from 'element-plus' +import { nextTick } from 'vue' +import ChatView from '../src/views/ChatView.vue' +import axios from 'axios' + +vi.mock('axios', () => ({ + default: { + get: vi.fn(), + post: vi.fn(), + }, +})) + +const mockedAxios = axios +let latestEventSource = null + +class MockEventSource { + constructor() { + latestEventSource = this + this.onmessage = null + this.onerror = null + } + + close() {} +} + +const LoginStub = { template: '
' } + +const makeRouter = () => + createRouter({ + history: createMemoryHistory(), + routes: [ + { path: '/chat/:id', name: 'chat', component: ChatView }, + { path: '/login', name: 'login', component: LoginStub }, + ], + }) + +const buildMessage = (overrides = {}) => ({ + message_id: 'msg-1', + session_id: 'session-1', + seq: 1, + role: 'USER', + type: 'user.prompt', + content: 'hello', + payload: null, + reply_to: null, + dedupe_key: null, + created_at: '2025-12-18T05:30:51.000000Z', + ...overrides, +}) + +describe('ChatView run.status handling', () => { + beforeEach(() => { + localStorage.clear() + vi.clearAllMocks() + latestEventSource = null + globalThis.EventSource = MockEventSource + globalThis.scrollTo = vi.fn() + }) + + it('locks input and filters run.status messages on initial load', 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' }, + }), + buildMessage({ + seq: 3, + role: 'AGENT', + type: 'agent.message', + content: 'reply', + }), + ] + + 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 sendButton = wrapper.find('button.send-btn') + expect(sendButton.element.disabled).toBe(true) + + const textarea = wrapper.find('textarea') + expect(textarea.element.disabled).toBe(false) + }) + + it('unlocks input when run.status DONE arrives via sse', 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' }, + }), + ] + + 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) + + const textarea = wrapper.find('textarea') + expect(textarea.element.disabled).toBe(false) + + latestEventSource.onmessage({ + data: JSON.stringify( + buildMessage({ + seq: 3, + role: 'SYSTEM', + type: 'run.status', + content: 'DONE', + payload: { status: 'DONE' }, + }) + ), + }) + + await flushPromises() + + expect(sendButton.element.disabled).toBe(false) + expect(textarea.element.disabled).toBe(false) + }) + + it('sends message on ctrl+enter', 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: {} }) + }) + mockedAxios.post.mockResolvedValue({ data: {} }) + + const wrapper = mount(ChatView, { + global: { + plugins: [router, ElementPlus], + }, + }) + + await flushPromises() + await nextTick() + + const textarea = wrapper.find('textarea') + await textarea.setValue('hello') + await textarea.trigger('keydown', { key: 'Enter', ctrlKey: true }) + await flushPromises() + + expect(mockedAxios.post).toHaveBeenCalledWith( + 'http://localhost:8000/api/sessions/session-1/messages', + expect.objectContaining({ + role: 'USER', + type: 'user.prompt', + content: 'hello', + }), + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer token', + }), + }) + ) + }) + + it('redirects to login when sse error returns 401', 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: [] } }) + } + if (url.endsWith('/me')) { + return Promise.reject({ response: { status: 401 } }) + } + return Promise.resolve({ data: {} }) + }) + + mount(ChatView, { + global: { + plugins: [router, ElementPlus], + }, + }) + + await flushPromises() + await nextTick() + + expect(latestEventSource).not.toBeNull() + latestEventSource.onerror() + await flushPromises() + + expect(router.currentRoute.value.name).toBe('login') + }) +}) diff --git a/tests/login.spec.js b/tests/login.spec.js new file mode 100644 index 0000000..a47b798 --- /dev/null +++ b/tests/login.spec.js @@ -0,0 +1,62 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' +import { createRouter, createMemoryHistory } from 'vue-router' +import ElementPlus from 'element-plus' +import LoginView from '../src/views/LoginView.vue' +import axios from 'axios' + +vi.mock('axios', () => ({ + default: { + post: vi.fn(), + }, +})) + +const mockedAxios = axios + +const makeRouter = () => + createRouter({ + history: createMemoryHistory(), + routes: [ + { path: '/', name: 'login', component: LoginView }, + { path: '/welcome', name: 'welcome', component: LoginView }, + ], + }) + +describe('LoginView enter submit', () => { + beforeEach(() => { + localStorage.clear() + vi.clearAllMocks() + }) + + it('submits login on form submit', async () => { + mockedAxios.post.mockResolvedValue({ + data: { token: 'token-123', user: { id: 'u1' } }, + }) + + const router = makeRouter() + router.push('/') + await router.isReady() + + const wrapper = mount(LoginView, { + global: { + plugins: [router, ElementPlus], + }, + }) + + const emailInput = wrapper.find('input[autocomplete="email"]') + const passwordInput = wrapper.find( + 'input[autocomplete="current-password"]' + ) + await emailInput.setValue('user@example.com') + await passwordInput.setValue('password') + + await wrapper.find('form').trigger('submit') + await flushPromises() + + expect(mockedAxios.post).toHaveBeenCalledWith( + 'http://localhost:8000/api/login', + { email: 'user@example.com', password: 'password' } + ) + expect(router.currentRoute.value.name).toBe('welcome') + }) +})