完善 ChatView 页面功能与用户交互逻辑
- 增加请求状态管理,区分 RUNNING 和 DONE 状态 - 优化消息列表归一化处理逻辑,提高代码可读性 - 添加按键事件支持 (Ctrl+Enter) 以改善发送消息体验 - 更新输入框与发送按钮的禁用逻辑,明确状态反馈 - 提升 SSE 连接恢复的安全性,增加鉴权检查 - 优化界面显示,新增快捷键提示及样式改进 - 移除 package-lock.json 中多余的 peer 标记
This commit is contained in:
14
package-lock.json
generated
14
package-lock.json
generated
@@ -197,7 +197,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
},
|
},
|
||||||
@@ -244,7 +243,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -1145,7 +1143,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz",
|
"resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz",
|
||||||
"integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
|
"integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/lodash": "*"
|
"@types/lodash": "*"
|
||||||
}
|
}
|
||||||
@@ -2338,7 +2335,6 @@
|
|||||||
"integrity": "sha512-GtldT42B8+jefDUC4yUKAvsaOrH7PDHmZxZXNgF2xMmymjUbRYJvpAybZAKEmXDGTM0mCsz8duOa4vTm5AY2Kg==",
|
"integrity": "sha512-GtldT42B8+jefDUC4yUKAvsaOrH7PDHmZxZXNgF2xMmymjUbRYJvpAybZAKEmXDGTM0mCsz8duOa4vTm5AY2Kg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@acemir/cssom": "^0.9.28",
|
"@acemir/cssom": "^0.9.28",
|
||||||
"@asamuzakjp/dom-selector": "^6.7.6",
|
"@asamuzakjp/dom-selector": "^6.7.6",
|
||||||
@@ -2377,15 +2373,13 @@
|
|||||||
"version": "4.17.21",
|
"version": "4.17.21",
|
||||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/lodash-es": {
|
"node_modules/lodash-es": {
|
||||||
"version": "4.17.21",
|
"version": "4.17.21",
|
||||||
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz",
|
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz",
|
||||||
"integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==",
|
"integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==",
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/lodash-unified": {
|
"node_modules/lodash-unified": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
@@ -2630,7 +2624,6 @@
|
|||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -2657,7 +2650,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.11",
|
"nanoid": "^3.3.11",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
@@ -3048,7 +3040,6 @@
|
|||||||
"integrity": "sha512-ITcnkFeR3+fI8P1wMgItjGrR10170d8auB4EpMLPqmx6uxElH3a/hHGQabSHKdqd4FXWO1nFIp9rRn7JQ34ACQ==",
|
"integrity": "sha512-ITcnkFeR3+fI8P1wMgItjGrR10170d8auB4EpMLPqmx6uxElH3a/hHGQabSHKdqd4FXWO1nFIp9rRn7JQ34ACQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
"fdir": "^6.5.0",
|
"fdir": "^6.5.0",
|
||||||
@@ -3201,7 +3192,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.25.tgz",
|
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.25.tgz",
|
||||||
"integrity": "sha512-YLVdgv2K13WJ6n+kD5owehKtEXwdwXuj2TTyJMsO7pSeKw2bfRNZGjhB7YzrpbMYj5b5QsUebHpOqR3R3ziy/g==",
|
"integrity": "sha512-YLVdgv2K13WJ6n+kD5owehKtEXwdwXuj2TTyJMsO7pSeKw2bfRNZGjhB7YzrpbMYj5b5QsUebHpOqR3R3ziy/g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/compiler-dom": "3.5.25",
|
"@vue/compiler-dom": "3.5.25",
|
||||||
"@vue/compiler-sfc": "3.5.25",
|
"@vue/compiler-sfc": "3.5.25",
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const inputText = ref('')
|
|||||||
const uploadFiles = ref([])
|
const uploadFiles = ref([])
|
||||||
const fileList = ref([])
|
const fileList = ref([])
|
||||||
const sending = ref(false)
|
const sending = ref(false)
|
||||||
|
const runStatus = ref('DONE')
|
||||||
const loadingMessages = ref(false)
|
const loadingMessages = ref(false)
|
||||||
const errorMessage = ref('')
|
const errorMessage = ref('')
|
||||||
const showJumpDown = ref(false)
|
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 upsertMessage = (msg) => {
|
||||||
const list = messagesState.value
|
const list = messagesState.value
|
||||||
const idx = list.findIndex(
|
const idx = list.findIndex(
|
||||||
@@ -106,6 +149,8 @@ const normalizedSummary = (data = {}) => ({
|
|||||||
tags: data.last_message_type ? [data.last_message_type] : [],
|
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 messages = computed(() => messagesState.value || [])
|
||||||
|
|
||||||
const handleFileChange = (uploadFile, uploadFilesList) => {
|
const handleFileChange = (uploadFile, uploadFilesList) => {
|
||||||
@@ -142,8 +187,19 @@ const handleFileRemove = (file, list) => {
|
|||||||
uploadFiles.value = filtered.map(toAttachment)
|
uploadFiles.value = filtered.map(toAttachment)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleComposerKeydown = (event) => {
|
||||||
|
if (event.key === 'Enter' && event.ctrlKey) {
|
||||||
|
event.preventDefault()
|
||||||
|
handleSend()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleSend = () => {
|
const handleSend = () => {
|
||||||
if (sending.value) return
|
if (sending.value) return
|
||||||
|
if (runActive.value) {
|
||||||
|
ElMessage.info('Agent 正在处理中,请稍候再试')
|
||||||
|
return
|
||||||
|
}
|
||||||
if (summary.status === 'CLOSED') {
|
if (summary.status === 'CLOSED') {
|
||||||
ElMessage.warning('会话已归档,无法发送')
|
ElMessage.warning('会话已归档,无法发送')
|
||||||
return
|
return
|
||||||
@@ -291,7 +347,7 @@ const fetchMessages = async () => {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
const list = data.data || []
|
const list = data.data || []
|
||||||
messagesState.value = list.map((msg) => normalizeMessage(msg))
|
messagesState.value = normalizeMessageList(list)
|
||||||
if (list.length) {
|
if (list.length) {
|
||||||
const last = list[list.length - 1]
|
const last = list[list.length - 1]
|
||||||
lastSeq.value = last.seq || 0
|
lastSeq.value = last.seq || 0
|
||||||
@@ -331,6 +387,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
|
||||||
const normalized = normalizeMessage(detail)
|
const normalized = normalizeMessage(detail)
|
||||||
upsertMessage(normalized)
|
upsertMessage(normalized)
|
||||||
} catch (err) {
|
} 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 = () => {
|
const startSse = () => {
|
||||||
if (typeof window === 'undefined' || typeof EventSource === 'undefined')
|
if (typeof window === 'undefined' || typeof EventSource === 'undefined')
|
||||||
return
|
return
|
||||||
@@ -391,6 +468,7 @@ const startSse = () => {
|
|||||||
fetchSingleMessage(data.message_id)
|
fetchSingleMessage(data.message_id)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (applyRunStatus(data)) return
|
||||||
const normalized = normalizeMessage(data)
|
const normalized = normalizeMessage(data)
|
||||||
upsertMessage(normalized)
|
upsertMessage(normalized)
|
||||||
nextTick(() => scrollToBottom())
|
nextTick(() => scrollToBottom())
|
||||||
@@ -400,7 +478,10 @@ const startSse = () => {
|
|||||||
}
|
}
|
||||||
es.onerror = () => {
|
es.onerror = () => {
|
||||||
stopSse()
|
stopSse()
|
||||||
sseRetryTimer.value = window.setTimeout(() => startSse(), 3000)
|
checkSseAuth().then((authed) => {
|
||||||
|
if (!authed) return
|
||||||
|
sseRetryTimer.value = window.setTimeout(() => startSse(), 3000)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
eventSource.value = es
|
eventSource.value = es
|
||||||
}
|
}
|
||||||
@@ -409,6 +490,7 @@ const applySession = () => {
|
|||||||
if (!sessionId.value) return
|
if (!sessionId.value) return
|
||||||
stopSse()
|
stopSse()
|
||||||
lastSeq.value = 0
|
lastSeq.value = 0
|
||||||
|
runStatus.value = 'DONE'
|
||||||
localStorage.setItem('ars-current-session', sessionId.value)
|
localStorage.setItem('ars-current-session', sessionId.value)
|
||||||
fetchSummary()
|
fetchSummary()
|
||||||
fetchMessages().then(() => {
|
fetchMessages().then(() => {
|
||||||
@@ -566,6 +648,7 @@ onBeforeUnmount(() => {
|
|||||||
multiple
|
multiple
|
||||||
accept="image/*,.pdf,.txt,.doc,.docx,.md"
|
accept="image/*,.pdf,.txt,.doc,.docx,.md"
|
||||||
:auto-upload="false"
|
:auto-upload="false"
|
||||||
|
:disabled="inputLocked"
|
||||||
:show-file-list="false"
|
:show-file-list="false"
|
||||||
:file-list="fileList"
|
:file-list="fileList"
|
||||||
:on-change="handleFileChange"
|
:on-change="handleFileChange"
|
||||||
@@ -581,17 +664,24 @@ onBeforeUnmount(() => {
|
|||||||
:autosize="{ minRows: 3, maxRows: 8 }"
|
:autosize="{ minRows: 3, maxRows: 8 }"
|
||||||
class="composer-input"
|
class="composer-input"
|
||||||
:placeholder="summary.status === 'CLOSED' ? '会话已归档,无法继续发送' : '输入消息,支持粘贴大段文本与附件...'"
|
:placeholder="summary.status === 'CLOSED' ? '会话已归档,无法继续发送' : '输入消息,支持粘贴大段文本与附件...'"
|
||||||
:disabled="summary.status === 'CLOSED'"
|
:disabled="inputLocked"
|
||||||
|
@keydown="handleComposerKeydown"
|
||||||
/>
|
/>
|
||||||
<el-button
|
<div class="send-wrap">
|
||||||
color="#1d4ed8"
|
<el-button
|
||||||
class="send-btn"
|
color="#1d4ed8"
|
||||||
:loading="sending"
|
class="send-btn"
|
||||||
:disabled="summary.status === 'CLOSED'"
|
:loading="sending || runActive"
|
||||||
@click="handleSend"
|
:disabled="inputLocked || runActive"
|
||||||
>
|
@click="handleSend"
|
||||||
发送
|
>
|
||||||
</el-button>
|
发送
|
||||||
|
</el-button>
|
||||||
|
<span class="send-hint">
|
||||||
|
<span class="keycap">Ctrl</span> +
|
||||||
|
<span class="keycap">Enter</span> 发送
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -909,6 +999,32 @@ onBeforeUnmount(() => {
|
|||||||
padding: 0 18px;
|
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 {
|
.pending-attachments {
|
||||||
padding: 10px 0 0;
|
padding: 10px 0 0;
|
||||||
border-top: 1px dashed rgba(0, 0, 0, 0.08);
|
border-top: 1px dashed rgba(0, 0, 0, 0.08);
|
||||||
@@ -989,5 +1105,10 @@ onBeforeUnmount(() => {
|
|||||||
.send-btn {
|
.send-btn {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.send-wrap {
|
||||||
|
width: 100%;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -58,7 +58,11 @@ const handleLogin = async () => {
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<el-form label-position="top" :model="form" @submit.prevent>
|
<el-form
|
||||||
|
label-position="top"
|
||||||
|
:model="form"
|
||||||
|
@submit.prevent="handleLogin"
|
||||||
|
>
|
||||||
<el-form-item label="邮箱">
|
<el-form-item label="邮箱">
|
||||||
<el-input
|
<el-input
|
||||||
v-model="form.email"
|
v-model="form.email"
|
||||||
@@ -82,7 +86,7 @@ const handleLogin = async () => {
|
|||||||
class="login-btn"
|
class="login-btn"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
color="#1d4ed8"
|
color="#1d4ed8"
|
||||||
@click="handleLogin"
|
native-type="submit"
|
||||||
>
|
>
|
||||||
进入系统
|
进入系统
|
||||||
</el-button>
|
</el-button>
|
||||||
|
|||||||
267
tests/chat-view.spec.js
Normal file
267
tests/chat-view.spec.js
Normal file
@@ -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: '<div />' }
|
||||||
|
|
||||||
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
62
tests/login.spec.js
Normal file
62
tests/login.spec.js
Normal file
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user