feat: AI对话

This commit is contained in:
zhangyh
2025-10-28 11:33:20 +08:00
parent 2a1023aea0
commit 0ce0c41dac
12 changed files with 358 additions and 111 deletions

View File

@@ -8,7 +8,12 @@
</div>
<template v-else>
<div class="content flex-1" v-if="!isLoading">
<NoticeList ref="noticeListRef" :list="messageList" />
<NoticeList
ref="noticeListRef"
:list="messageList"
:is-streaming="isStreaming"
:streaming-message="currentStreamingMessage"
/>
</div>
<div class="footer" v-if="!isLoading">
<InputArea @send-message="handleSendMessage" />
@@ -26,9 +31,12 @@ import InputArea from './components/InputArea.vue'
import GenerateLoading from './components/GenerateLoading.vue'
import { ref, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { fetchEventSource } from '@microsoft/fetch-event-source'
import { useUserInfoStore } from '@/stores'
import { streamChatAddress } from '@/api/workshop'
import { generateUUID } from '@/utils/tools'
const router = useRouter()
const userInfoStore = useUserInfoStore()
defineOptions({
name: 'asistant'
@@ -40,47 +48,21 @@ interface NoticeListRef {
}
// 定义消息类型
interface ChatMessage {
id: string
sessionId: string | number
type: string
content: string
userId: string
time: string
thumb: string
timestamp: string
id?: string
self?: boolean
}
const isLoading = ref<boolean>(false)
const noticeListRef = ref<NoticeListRef | null>(null)
const messageList = ref<ChatMessage[]>([
{
id: '1',
content: 'I want a chic outfit for dinner.',
userId: '1',
time: '14:30',
thumb: ''
},
{
id: '2',
content:
"Hey there! A chic dinner outfit, I love that! To give you the perfect recommendations, tell me: is this a romantic date, business dinner, or celebration with friends? And what's your go-to style vibe: classic elegance or something with more edge?",
userId: '2',
time: '14:31',
thumb: 'https://files-dev.deercal.com/uploads/platforms/logo_code/669933e1b873e798.jpg'
},
{
id: '3',
content: "It's a romantic date, and I prefer classic elegance.",
userId: '1',
time: '14:32',
thumb: ''
},
{
id: '4',
content:
"Perfect! For a romantic dinner with classic elegance, I'd suggest a little black dress with delicate jewelry and elegant heels. Would you like me to show you some specific options?",
userId: '2',
time: '14:33',
thumb: 'https://files-dev.deercal.com/uploads/platforms/logo_code/669933e1b873e798.jpg'
}
])
const messageList = ref<ChatMessage[]>([])
// 流式消息相关状态
const isStreaming = ref<boolean>(false)
const currentStreamingMessage = ref<ChatMessage | null>(null)
onMounted(() => {})
@@ -90,30 +72,134 @@ onUnmounted(() => {
const handleSendMessage = (message: string): void => {
console.log('发送:', message)
handleFetchMessage()
// 添加用户消息到列表
const userMessage: ChatMessage = {
id: generateUUID(),
type: 'text',
content: message,
timestamp: new Date().toISOString(),
sessionId: (userInfoStore.state.userInfo as any).id,
self: true
}
messageList.value.push(userMessage)
// 开始流式接收AI回复
handleFetchMessage(message)
}
const abort = new AbortController()
const handleFetchMessage = () => {
fetchEventSource('/api/sse', {
method: 'POST',
openWhenHidden: true,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
signal: abort.signal,
onopen: async (res) => {
console.log('连接建立', res)
},
onmessage: (event) => {
console.log('收到消息', event)
},
onerror: (error) => {
console.log('错误', error)
},
onclose: () => {
console.log('连接关闭')
}
const handleFetchMessage = (message: string) => {
const params = {
message: message,
sessionId: (userInfoStore.state.userInfo as any).id,
gender: 'male'
}
// 创建AI消息对象
const aiMessage: ChatMessage = {
id: '',
type: 'text',
content: '',
timestamp: new Date().toISOString(),
sessionId: (userInfoStore.state.userInfo as any).id
}
// 添加到消息列表
isStreaming.value = true
messageList.value.push(aiMessage)
currentStreamingMessage.value = aiMessage
// 直接使用 fetch 进行流式请求
const token = userInfoStore.state.token
const baseURL = import.meta.env.MODE === 'development' ? '' : import.meta.env.VITE_APP_URL
// 构建查询参数
const queryParams = new URLSearchParams()
Object.entries(params).forEach(([key, value]) => {
queryParams.append(key, String(value))
})
const url = `${baseURL}${streamChatAddress}?${queryParams.toString()}`
fetch(url, {
method: 'GET',
headers: {
Authorization: token,
'Content-Type': 'application/json'
},
credentials: 'include'
})
.then(async (response) => {
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`)
let contentBody = ''
let buffer = ''
const reader = response.body?.getReader()
if (!reader) throw new Error('无法获取流读取器')
const decoder = new TextDecoder()
try {
let flag = true
while (flag) {
const { done, value } = await reader.read()
if (done) {
console.log('传输结束 end---', contentBody)
isStreaming.value = false
currentStreamingMessage.value = null
flag = false
break
}
buffer += decoder.decode(value, { stream: true })
// 优先按空行拆分事件块SSE标准
let events = buffer.split(/\n\n/)
buffer = events.pop() // 保留不完整块
for (let event of events) {
if (!event.trim()) continue
// 过滤掉 id: 等字段,只取 data:
const dataLines = event
.split(/\n/)
.filter((line) => line.startsWith('data:'))
.map((line) => line.replace(/^data:\s*/, '').trim())
if (dataLines.length === 0) continue
const jsonText = dataLines.join('\n')
try {
const jsonData = JSON.parse(jsonText)
if (jsonData.content && jsonData.content.length > 0 && jsonData.type !== 'end') {
contentBody += jsonData.content
currentStreamingMessage.value.content = contentBody
}
} catch (e) {
// JSON 不完整:保留到下一次循环
if (!jsonText.trim().endsWith('}')) {
buffer = 'data: ' + jsonText // 重新放回缓存
continue
} else {
console.warn('⚠️ JSON 格式错误,跳过:', jsonText)
}
}
}
}
} catch (error) {
console.error('流式传输错误:', error)
isStreaming.value = false
currentStreamingMessage.value = null
} finally {
reader.releaseLock()
}
})
.catch((error) => {
console.error('fetch请求失败:', error)
isStreaming.value = false
currentStreamingMessage.value = null
})
}
const handleContinue = () => {