229 lines
6.0 KiB
Vue
229 lines
6.0 KiB
Vue
<script setup lang="ts">
|
||
import type { ChatMessage } from '@/types/chat'
|
||
import { useSessionManager } from '@/composables/useSessionManager'
|
||
import SessionSelector from '@/components/SessionSelector.vue'
|
||
import { useChatSessionStore } from '@/stores/modules/chatSession'
|
||
import { useScroll } from '@vueuse/core'
|
||
import { streamApi } from '@/api/agent'
|
||
|
||
|
||
const sessionManager = useSessionManager()
|
||
sessionManager.initialize()
|
||
const sessionStore = useChatSessionStore()
|
||
|
||
// Messages from current session - get directly from store
|
||
const messages = computed(() => sessionStore.currentSession?.messages || [])
|
||
|
||
const inputText = ref('')
|
||
const isLoading = ref(false)
|
||
const messagesContainer = ref<HTMLElement>()
|
||
|
||
// 流式响应相关状态
|
||
const currentAiMessage = ref<ChatMessage | null>(null)
|
||
const isStreaming = ref(false)
|
||
const streamController = ref<AbortController | null>(null)
|
||
|
||
const { arrivedState } = useScroll(messagesContainer, {
|
||
offset: { bottom: 100 },
|
||
})
|
||
|
||
// Auto-scroll to bottom when new messages arrive
|
||
watch(messages, () => {
|
||
nextTick(() => {
|
||
if (messagesContainer.value && arrivedState.bottom) {
|
||
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
|
||
}
|
||
})
|
||
}, { deep: true })
|
||
|
||
function handleSend(text: string) {
|
||
if (!text.trim())
|
||
return
|
||
|
||
// 添加用户消息到当前会话
|
||
const userMessage: ChatMessage = {
|
||
id: Date.now(),
|
||
content: text,
|
||
sender: 'user',
|
||
timestamp: new Date(),
|
||
status: 'sent',
|
||
}
|
||
const memory = sessionManager.handleNewMessage(userMessage)
|
||
|
||
// 创建 AI 消息占位符
|
||
const aiMessage: ChatMessage = {
|
||
id: Date.now() + 1,
|
||
content: '',
|
||
sender: 'ai',
|
||
timestamp: new Date(),
|
||
status: 'sending',
|
||
}
|
||
sessionManager.handleNewMessage(aiMessage)
|
||
|
||
// 设置当前 AI 消息引用(获取最后一条消息)
|
||
const currentSession = sessionStore.currentSession
|
||
const sessionId = sessionStore.currentSessionId
|
||
if (currentSession && currentSession.messages.length > 0) {
|
||
currentAiMessage.value = currentSession.messages[currentSession.messages.length - 1]
|
||
}
|
||
|
||
// 清空输入
|
||
inputText.value = ''
|
||
|
||
// 设置加载状态
|
||
isLoading.value = true
|
||
isStreaming.value = true
|
||
|
||
// 创建 AbortController
|
||
const controller = new AbortController()
|
||
streamController.value = controller
|
||
|
||
// 调用流式 API,传递 memory 参数
|
||
streamApi(
|
||
{
|
||
message: text,
|
||
options: memory ? { memory } : undefined
|
||
},
|
||
{
|
||
onopen(response) {
|
||
console.log('SSE 连接已建立:', response)
|
||
},
|
||
onmessage(data) {
|
||
try {
|
||
const parsed = JSON.parse(data) as { text: string }
|
||
if (parsed.text && currentAiMessage.value && sessionId) {
|
||
// 使用 store 更新消息内容
|
||
sessionStore.updateMessageContent(sessionId, currentAiMessage.value.id, parsed.text)
|
||
// 更新本地引用
|
||
currentAiMessage.value.content += parsed.text
|
||
}
|
||
} catch (error) {
|
||
console.error('解析 SSE 数据失败:', error)
|
||
}
|
||
},
|
||
onclose() {
|
||
console.log('SSE 连接已关闭')
|
||
completeMessage()
|
||
},
|
||
onerror(error) {
|
||
console.error('SSE 连接错误:', error)
|
||
handleStreamError(error)
|
||
}
|
||
},
|
||
{ signal: controller.signal }
|
||
)
|
||
}
|
||
|
||
|
||
// 完成消息处理
|
||
function completeMessage() {
|
||
if (currentAiMessage.value && sessionStore.currentSession) {
|
||
const sessionId = sessionStore.currentSessionId
|
||
const messageId = currentAiMessage.value.id
|
||
|
||
// 更新消息状态为 'sent'
|
||
const session = sessionStore.currentSession
|
||
if (session && sessionId) {
|
||
const updatedMessages = session.messages.map(msg =>
|
||
msg.id === messageId ? { ...msg, status: 'sent' as const } : msg
|
||
)
|
||
sessionStore.updateSession(sessionId, { messages: updatedMessages })
|
||
}
|
||
}
|
||
|
||
cleanupStream()
|
||
}
|
||
|
||
// 处理流式错误
|
||
function handleStreamError(error: Error) {
|
||
if (currentAiMessage.value && sessionStore.currentSession) {
|
||
const sessionId = sessionStore.currentSessionId
|
||
const messageId = currentAiMessage.value.id
|
||
|
||
// 更新消息状态为 'error' 并添加错误标记
|
||
const session = sessionStore.currentSession
|
||
if (session && sessionId) {
|
||
const updatedMessages = session.messages.map(msg => {
|
||
if (msg.id === messageId) {
|
||
return {
|
||
...msg,
|
||
status: 'error' as const,
|
||
content: msg.content + ' (连接错误)'
|
||
}
|
||
}
|
||
return msg
|
||
})
|
||
sessionStore.updateSession(sessionId, { messages: updatedMessages })
|
||
}
|
||
}
|
||
|
||
// 显示错误提示(可集成 vant 的 Toast 组件)
|
||
console.error('AI 响应错误:', error)
|
||
cleanupStream()
|
||
}
|
||
|
||
// 清理流式连接
|
||
function cleanupStream() {
|
||
isLoading.value = false
|
||
isStreaming.value = false
|
||
streamController.value = null
|
||
currentAiMessage.value = null
|
||
}
|
||
|
||
function handleKeydown(e: KeyboardEvent) {
|
||
// You can add additional keyboard shortcuts here
|
||
console.log('Keydown event:', e.key)
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div class="bg-gray-50 flex flex-col h-screen dark:bg-gray-950">
|
||
|
||
<!-- Session selector -->
|
||
<SessionSelector />
|
||
|
||
<!-- Messages container -->
|
||
<div
|
||
ref="messagesContainer"
|
||
class="p-4 pb-24 flex-1 overflow-auto"
|
||
>
|
||
<van-empty
|
||
v-if="messages.length === 0"
|
||
description="No messages yet. Start a conversation!"
|
||
class="mt-20"
|
||
/>
|
||
|
||
<div v-else>
|
||
<ChatBubble
|
||
v-for="message in messages"
|
||
:key="message.id"
|
||
:message="message"
|
||
:show-timestamp="true"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Input area -->
|
||
<div class="bottom-0 left-0 right-0 fixed z-10">
|
||
<ChatInput
|
||
v-model="inputText"
|
||
:disabled="isLoading"
|
||
:loading="isLoading"
|
||
placeholder="Type your message..."
|
||
@send="handleSend"
|
||
@keydown="handleKeydown"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<route lang="json5">
|
||
{
|
||
name: 'LLMChat',
|
||
meta: {
|
||
title: 'AI Chat',
|
||
requiresAuth: false
|
||
}
|
||
}
|
||
</route>
|