WebAgent/src/pages/llm-chat/index.vue

221 lines
5.3 KiB
Vue
Raw Normal View History

<script setup lang="ts">
import type { ChatMessage } from '@/types/chat'
import { useScroll } from '@vueuse/core'
import { streamApi } from '@/api/agent'
// Sample chat data for demonstration
const messages = ref<ChatMessage[]>([
])
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',
}
messages.value.push(userMessage)
// 创建 AI 消息占位符
const aiMessage: ChatMessage = {
id: Date.now() + 1,
content: '',
sender: 'ai',
timestamp: new Date(),
status: 'sending',
}
messages.value.push(aiMessage)
currentAiMessage.value = aiMessage
// 清空输入
inputText.value = ''
// 设置加载状态
isLoading.value = true
isStreaming.value = true
// 创建 AbortController虽然用户不需要中止功能但保留以备后用
const controller = new AbortController()
streamController.value = controller
// 调用流式 API
streamApi(
text,
{
onopen(response) {
console.log('SSE 连接已建立:', response)
},
onmessage(data) {
try {
const parsed = JSON.parse(data) as { text: string }
if (parsed.text) {
// 打字机效果:逐字符追加
typewriterEffect(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 typewriterEffect(text: string) {
if (!currentAiMessage.value)
return
const duration = 100 // 每个字符间隔(毫秒)
const fullText = currentAiMessage.value.content + text
function updateChar(index: number) {
if (!currentAiMessage.value)
return
if (index < fullText.length) {
currentAiMessage.value.content = fullText.slice(0, index + 1)
// 触发 Vue 响应式更新
const messageIndex = messages.value.findIndex(m => m.id === currentAiMessage.value?.id)
if (messageIndex !== -1) {
messages.value[messageIndex] = { ...currentAiMessage.value }
}
setTimeout(() => updateChar(index + 1), duration)
}
}
updateChar(currentAiMessage.value.content.length)
}
// 完成消息处理
function completeMessage() {
if (currentAiMessage.value) {
currentAiMessage.value.status = 'sent'
const messageIndex = messages.value.findIndex(m => m.id === currentAiMessage.value?.id)
if (messageIndex !== -1) {
messages.value[messageIndex] = { ...currentAiMessage.value }
}
}
cleanupStream()
}
// 处理流式错误
function handleStreamError(error: Error) {
if (currentAiMessage.value) {
currentAiMessage.value.status = 'error'
currentAiMessage.value.content += ' (连接错误)'
const messageIndex = messages.value.findIndex(m => m.id === currentAiMessage.value?.id)
if (messageIndex !== -1) {
messages.value[messageIndex] = { ...currentAiMessage.value }
}
}
// 显示错误提示(可集成 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">
<!-- Messages container -->
<van-list
ref="messagesContainer"
class="p-4 pb-24 flex-1"
:finished="true"
:loading="false"
finished-text=""
loading-text=""
>
<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>
</van-list>
<!-- 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>