2025-10-14 16:42:09 +08:00
|
|
|
|
<template>
|
|
|
|
|
|
<div class="asistant-container flex flex-column">
|
|
|
|
|
|
<div class="header">
|
2025-11-17 11:24:46 +08:00
|
|
|
|
<HeaderTitle hasSetting styleType="3" />
|
2025-10-14 16:42:09 +08:00
|
|
|
|
</div>
|
2025-10-15 15:46:01 +08:00
|
|
|
|
<div class="loading-container" v-if="isLoading">
|
2025-10-20 15:32:40 +08:00
|
|
|
|
<GenerateLoading />
|
2025-10-14 16:42:09 +08:00
|
|
|
|
</div>
|
2025-10-15 15:46:01 +08:00
|
|
|
|
<template v-else>
|
|
|
|
|
|
<div class="content flex-1" v-if="!isLoading">
|
2025-10-28 11:33:20 +08:00
|
|
|
|
<NoticeList
|
|
|
|
|
|
ref="noticeListRef"
|
|
|
|
|
|
:list="messageList"
|
|
|
|
|
|
:is-streaming="isStreaming"
|
|
|
|
|
|
:streaming-message="currentStreamingMessage"
|
|
|
|
|
|
/>
|
2025-10-15 15:46:01 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<div class="footer" v-if="!isLoading">
|
|
|
|
|
|
<InputArea @send-message="handleSendMessage" />
|
|
|
|
|
|
<div class="continue">
|
2025-10-16 16:03:01 +08:00
|
|
|
|
<button class="btn" @click="handleContinue">Continue</button>
|
2025-10-15 15:46:01 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
2025-10-14 16:42:09 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
<script setup lang="ts">
|
|
|
|
|
|
import HeaderTitle from '@/components/HeaderTitle.vue'
|
|
|
|
|
|
import NoticeList from './components/NoticeList.vue'
|
|
|
|
|
|
import InputArea from './components/InputArea.vue'
|
2025-10-20 15:32:40 +08:00
|
|
|
|
import GenerateLoading from './components/GenerateLoading.vue'
|
2025-11-03 15:52:20 +08:00
|
|
|
|
import { ref, onMounted, onUnmounted, onActivated } from 'vue'
|
2025-11-17 17:38:57 +08:00
|
|
|
|
import { useRouter, useRoute } from 'vue-router'
|
2025-10-30 15:00:44 +08:00
|
|
|
|
import { useUserInfoStore, useGenerateStore } from '@/stores'
|
2025-10-28 11:33:20 +08:00
|
|
|
|
import { streamChatAddress } from '@/api/workshop'
|
|
|
|
|
|
import { generateUUID } from '@/utils/tools'
|
2025-10-30 13:16:50 +08:00
|
|
|
|
import { showToast } from 'vant'
|
2025-10-24 17:37:15 +08:00
|
|
|
|
|
2025-10-16 16:03:01 +08:00
|
|
|
|
const router = useRouter()
|
2025-11-17 17:38:57 +08:00
|
|
|
|
const route = useRoute()
|
2025-10-30 14:22:24 +08:00
|
|
|
|
const generateStore = useGenerateStore()
|
2025-10-28 11:33:20 +08:00
|
|
|
|
const userInfoStore = useUserInfoStore()
|
2025-10-14 16:42:09 +08:00
|
|
|
|
|
2025-10-21 11:21:15 +08:00
|
|
|
|
defineOptions({
|
2025-10-24 17:37:15 +08:00
|
|
|
|
name: 'asistant'
|
2025-10-21 11:21:15 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
2025-10-14 16:42:09 +08:00
|
|
|
|
// 定义NoticeList组件引用类型
|
|
|
|
|
|
interface NoticeListRef {
|
|
|
|
|
|
simulateSendMessage: () => void
|
2025-10-30 15:00:44 +08:00
|
|
|
|
scrollToBottom: () => void
|
2025-10-14 16:42:09 +08:00
|
|
|
|
}
|
2025-10-16 16:03:01 +08:00
|
|
|
|
// 定义消息类型
|
|
|
|
|
|
interface ChatMessage {
|
2025-10-28 11:33:20 +08:00
|
|
|
|
sessionId: string | number
|
|
|
|
|
|
type: string
|
2025-10-16 16:03:01 +08:00
|
|
|
|
content: string
|
2025-10-28 11:33:20 +08:00
|
|
|
|
timestamp: string
|
|
|
|
|
|
id?: string
|
|
|
|
|
|
self?: boolean
|
2025-10-16 16:03:01 +08:00
|
|
|
|
}
|
2025-10-14 16:42:09 +08:00
|
|
|
|
|
|
|
|
|
|
const isLoading = ref<boolean>(false)
|
|
|
|
|
|
const noticeListRef = ref<NoticeListRef | null>(null)
|
2025-10-28 11:33:20 +08:00
|
|
|
|
const messageList = ref<ChatMessage[]>([])
|
|
|
|
|
|
|
|
|
|
|
|
// 流式消息相关状态
|
|
|
|
|
|
const isStreaming = ref<boolean>(false)
|
|
|
|
|
|
const currentStreamingMessage = ref<ChatMessage | null>(null)
|
2025-11-03 15:52:20 +08:00
|
|
|
|
const sessionId = ref<string>('')
|
|
|
|
|
|
|
2025-11-17 17:38:57 +08:00
|
|
|
|
const sendPrefilledMessage = () => {
|
|
|
|
|
|
const { message, ...restQuery } = route.query
|
|
|
|
|
|
if (typeof message === 'string' && message.trim()) {
|
|
|
|
|
|
handleSendMessage(message)
|
|
|
|
|
|
router.replace({
|
|
|
|
|
|
path: route.path,
|
|
|
|
|
|
query: restQuery
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-03 15:52:20 +08:00
|
|
|
|
onMounted(() => {
|
|
|
|
|
|
sessionId.value = Math.floor(Date.now() / 1000).toString()
|
|
|
|
|
|
generateStore.setSessionId(sessionId.value)
|
|
|
|
|
|
})
|
2025-10-14 16:42:09 +08:00
|
|
|
|
|
2025-10-30 15:00:44 +08:00
|
|
|
|
onActivated(() => {
|
2025-11-20 14:29:41 +08:00
|
|
|
|
sendPrefilledMessage()
|
2025-10-30 15:00:44 +08:00
|
|
|
|
noticeListRef.value?.scrollToBottom()
|
|
|
|
|
|
})
|
2025-10-14 16:42:09 +08:00
|
|
|
|
|
2025-10-24 17:37:15 +08:00
|
|
|
|
onUnmounted(() => {
|
|
|
|
|
|
abort.abort()
|
2025-10-21 11:21:15 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
2025-10-14 16:42:09 +08:00
|
|
|
|
const handleSendMessage = (message: string): void => {
|
2025-10-24 17:37:15 +08:00
|
|
|
|
console.log('发送:', message)
|
2025-10-28 11:33:20 +08:00
|
|
|
|
|
|
|
|
|
|
// 添加用户消息到列表
|
|
|
|
|
|
const userMessage: ChatMessage = {
|
|
|
|
|
|
id: generateUUID(),
|
|
|
|
|
|
type: 'text',
|
|
|
|
|
|
content: message,
|
|
|
|
|
|
timestamp: new Date().toISOString(),
|
2025-11-03 15:52:20 +08:00
|
|
|
|
sessionId: sessionId.value,
|
2025-10-28 11:33:20 +08:00
|
|
|
|
self: true
|
|
|
|
|
|
}
|
|
|
|
|
|
messageList.value.push(userMessage)
|
|
|
|
|
|
|
|
|
|
|
|
// 开始流式接收AI回复
|
|
|
|
|
|
handleFetchMessage(message)
|
2025-10-24 17:37:15 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const abort = new AbortController()
|
2025-10-28 11:33:20 +08:00
|
|
|
|
const handleFetchMessage = (message: string) => {
|
|
|
|
|
|
const params = {
|
|
|
|
|
|
message: message,
|
2025-11-03 15:52:20 +08:00
|
|
|
|
sessionId: sessionId.value,
|
2025-10-28 11:33:20 +08:00
|
|
|
|
gender: 'male'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 创建AI消息对象
|
|
|
|
|
|
const aiMessage: ChatMessage = {
|
2025-10-30 15:19:46 +08:00
|
|
|
|
id: generateUUID(),
|
2025-10-28 11:33:20 +08:00
|
|
|
|
type: 'text',
|
|
|
|
|
|
content: '',
|
|
|
|
|
|
timestamp: new Date().toISOString(),
|
2025-11-03 15:52:20 +08:00
|
|
|
|
sessionId: sessionId.value
|
2025-10-28 11:33:20 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 添加到消息列表
|
|
|
|
|
|
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'
|
2025-10-24 17:37:15 +08:00
|
|
|
|
},
|
2025-10-28 11:33:20 +08:00
|
|
|
|
credentials: 'include'
|
2025-10-16 16:03:01 +08:00
|
|
|
|
})
|
2025-10-28 11:33:20 +08:00
|
|
|
|
.then(async (response) => {
|
2025-11-03 15:46:16 +08:00
|
|
|
|
// 检查响应内容类型,判断是否为流式响应
|
|
|
|
|
|
const contentType = response.headers.get('content-type') || ''
|
2025-11-03 15:52:20 +08:00
|
|
|
|
const isStreamResponse =
|
|
|
|
|
|
contentType.includes('text/event-stream') || contentType.includes('stream')
|
2025-11-03 15:46:16 +08:00
|
|
|
|
|
2025-10-30 13:16:50 +08:00
|
|
|
|
if (!response.ok) {
|
2025-11-03 15:46:16 +08:00
|
|
|
|
// 非流式错误响应,使用 text() 读取错误信息
|
|
|
|
|
|
const errorText = await response.text()
|
|
|
|
|
|
console.error('请求错误:', errorText)
|
2025-10-30 13:16:50 +08:00
|
|
|
|
showToast({
|
|
|
|
|
|
message: `failed to fetch: ${response.status}`,
|
|
|
|
|
|
position: 'top',
|
|
|
|
|
|
icon: 'none'
|
|
|
|
|
|
})
|
2025-11-03 15:46:16 +08:00
|
|
|
|
throw new Error(`发起对话错误--- ${response.status}: ${errorText}`)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 不是流式响应,使用 text()读取错误信息
|
|
|
|
|
|
if (!isStreamResponse) {
|
|
|
|
|
|
const text = await response.text()
|
2025-11-03 15:52:20 +08:00
|
|
|
|
|
2025-11-03 15:46:16 +08:00
|
|
|
|
try {
|
|
|
|
|
|
const errorData = JSON.parse(text)
|
|
|
|
|
|
if (errorData.message || errorData.error) {
|
|
|
|
|
|
showToast({
|
|
|
|
|
|
message: errorData.message || errorData.error || '请求失败',
|
|
|
|
|
|
position: 'top',
|
|
|
|
|
|
icon: 'none'
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
// 如果不是 JSON,直接显示文本内容
|
|
|
|
|
|
showToast({
|
|
|
|
|
|
message: text || '请求失败',
|
|
|
|
|
|
position: 'top',
|
|
|
|
|
|
icon: 'none'
|
|
|
|
|
|
})
|
|
|
|
|
|
throw new Error(text || '请求失败')
|
|
|
|
|
|
}
|
2025-11-03 15:52:20 +08:00
|
|
|
|
|
2025-11-03 15:46:16 +08:00
|
|
|
|
isStreaming.value = false
|
|
|
|
|
|
currentStreamingMessage.value = null
|
|
|
|
|
|
return
|
2025-10-30 13:16:50 +08:00
|
|
|
|
}
|
2025-10-28 11:33:20 +08:00
|
|
|
|
|
2025-11-03 15:46:16 +08:00
|
|
|
|
// 流式响应处理
|
2025-10-28 11:33:20 +08:00
|
|
|
|
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
|
|
|
|
|
|
})
|
2025-10-16 16:03:01 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const handleContinue = () => {
|
|
|
|
|
|
// router.push('/workshop/selectStyle')
|
|
|
|
|
|
// 模拟接口之后再跳转
|
|
|
|
|
|
isLoading.value = true
|
2025-10-30 14:22:24 +08:00
|
|
|
|
generateStore.clearProductData()
|
2025-10-14 16:42:09 +08:00
|
|
|
|
setTimeout(() => {
|
2025-10-16 16:03:01 +08:00
|
|
|
|
router.push('/workshop/selectStyle')
|
2025-10-21 11:21:15 +08:00
|
|
|
|
isLoading.value = false
|
2025-10-16 16:03:01 +08:00
|
|
|
|
}, 1000)
|
2025-10-14 16:42:09 +08:00
|
|
|
|
}
|
|
|
|
|
|
</script>
|
|
|
|
|
|
<style lang="less" scoped>
|
|
|
|
|
|
.asistant-container {
|
|
|
|
|
|
height: 100vh;
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.header {
|
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.content {
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.footer {
|
|
|
|
|
|
flex-shrink: 0;
|
2025-10-15 15:46:01 +08:00
|
|
|
|
|
2025-10-14 16:42:09 +08:00
|
|
|
|
.continue {
|
|
|
|
|
|
font-family: 'satoshiRegular';
|
|
|
|
|
|
font-size: 3.6rem;
|
|
|
|
|
|
color: #fff;
|
|
|
|
|
|
text-align: right;
|
|
|
|
|
|
padding: 2.6rem 4.5rem;
|
|
|
|
|
|
.btn {
|
|
|
|
|
|
border-radius: 7px;
|
|
|
|
|
|
background-color: #000;
|
|
|
|
|
|
width: 24.6rem;
|
|
|
|
|
|
height: 5.9rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-15 15:46:01 +08:00
|
|
|
|
.loading-container {
|
2025-10-14 16:42:09 +08:00
|
|
|
|
flex: 1;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
background-color: #fff;
|
|
|
|
|
|
}
|
|
|
|
|
|
</style>
|