468 lines
12 KiB
Vue
468 lines
12 KiB
Vue
<template>
|
||
<div class="agent-container flex flex-col">
|
||
<div class="agent-header flex align-center space-between">
|
||
<div class="header-title-wrapper">
|
||
<div class="agent-title">{{ props.title }}</div>
|
||
<div class="agent-name">AI Assistant 1.0</div>
|
||
</div>
|
||
<SvgIcon name="equal" color="#0d0d0d" size="24" />
|
||
</div>
|
||
<div class="agent-body flex-1 flex flex-col">
|
||
<List ref="listRef" :message-list="messageList" @regenerate="handleRegenerate" />
|
||
<Input
|
||
ref="inputRef"
|
||
is-agent-mode
|
||
:generating="isGenerating"
|
||
@send="handleSendMessage"
|
||
@pause="handlePause"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, reactive, computed, onUnmounted, onMounted, nextTick, watch } from 'vue'
|
||
import List from './List.vue'
|
||
import Input from '../../components/Input.vue'
|
||
import { fetchAgentReply } from '@/api/agent'
|
||
import type { AgentParamsType } from '@/api/agent'
|
||
import { useUserInfoStore, useProjectStore } from '@/stores'
|
||
import { useAgentStore } from '@/stores/agent'
|
||
|
||
const userStore = useUserInfoStore()
|
||
const agentStore = useAgentStore()
|
||
const projectStore = useProjectStore()
|
||
|
||
const emits = defineEmits(['update:sketchList'])
|
||
const props = withDefaults(
|
||
defineProps<{
|
||
title: string
|
||
}>(),
|
||
{
|
||
title: 'Retro Sofa Sketch'
|
||
}
|
||
)
|
||
|
||
const messageList = ref([])
|
||
|
||
const listRef = ref()
|
||
const inputRef = ref()
|
||
const isGenerating = ref(false)
|
||
const isPaused = ref(false) // 标记是否为主动暂停
|
||
const params = reactive<AgentParamsType>({
|
||
projectID: projectStore.state.id,
|
||
message: '',
|
||
token: userStore.state.token,
|
||
versionID: '',
|
||
configParams: {
|
||
type: '',
|
||
region: '',
|
||
style: ''
|
||
},
|
||
imageUrlList: []
|
||
})
|
||
|
||
const sketchList = ref([])
|
||
watch(
|
||
sketchList,
|
||
(newVal) => {
|
||
emits('update:sketchList', newVal)
|
||
},
|
||
{ deep: true }
|
||
)
|
||
|
||
// 每次请求时创建新的 AbortController
|
||
let abort: AbortController
|
||
|
||
const createAbortController = () => {
|
||
// 如果已有未完成的中止,先中止它
|
||
if (abort) {
|
||
abort.abort()
|
||
}
|
||
abort = new AbortController()
|
||
return abort
|
||
}
|
||
|
||
onUnmounted(() => {
|
||
abort?.abort()
|
||
})
|
||
|
||
onMounted(() => {
|
||
// 检查 store 中是否有初始项目数据
|
||
// projectStore.setId('1') // 临时设置项目ID为1,实际应用中应根据上下文动态设置
|
||
const initialData = agentStore.getInitialProjectData
|
||
if (initialData) {
|
||
// 等待页面渲染完成后自动发送初始消息
|
||
params.configParams = {
|
||
type: initialData.type,
|
||
region: initialData.area,
|
||
style: initialData.style,
|
||
temperature: 0.7
|
||
}
|
||
handleSendMessage({
|
||
text: initialData.text,
|
||
images: initialData.images
|
||
})
|
||
// 更新 configParams
|
||
|
||
// 清空初始数据
|
||
agentStore.clearInitialProjectData()
|
||
}
|
||
})
|
||
|
||
const handleSendMessage = async (
|
||
message: {
|
||
text: string
|
||
images: Array<{ url: string; name: string }>
|
||
tempImages: any[]
|
||
},
|
||
skipUserMessage = false
|
||
) => {
|
||
isPaused.value = false
|
||
isGenerating.value = true
|
||
params.message = message.text
|
||
params.imageUrlList = message.images || []
|
||
|
||
// 如果不是重新生成模式,则添加用户消息到列表
|
||
if (!skipUserMessage) {
|
||
messageList.value.push({
|
||
id: messageList.value.length + 1,
|
||
text: message.text,
|
||
isUser: true,
|
||
imageUrls: message.tempImages
|
||
})
|
||
}
|
||
|
||
// Add AI loading message
|
||
const aiMessage = reactive({
|
||
id: messageList.value.length + 1,
|
||
text: '',
|
||
isUser: false,
|
||
loading: true,
|
||
thinking: false,
|
||
thinkingText: '',
|
||
thinkingCollapsed: false,
|
||
streaming: true
|
||
})
|
||
messageList.value.push(aiMessage)
|
||
|
||
// 创建新的 AbortController
|
||
const abortController = createAbortController()
|
||
|
||
// console.log('token---', params.token, '参数---', params)
|
||
|
||
try {
|
||
const urlParams = new URLSearchParams<AgentParamsType>({
|
||
...params,
|
||
configParams: JSON.stringify(params.configParams)
|
||
})
|
||
const BASEURL = import.meta.env.VITE_APP_URL
|
||
const response = await fetch(`${BASEURL}/api/ai-design/chat?${urlParams.toString()}`, {
|
||
method: 'GET',
|
||
signal: abortController.signal
|
||
})
|
||
|
||
// 检查响应内容类型,判断是否为流式响应
|
||
const contentType = response.headers.get('content-type') || ''
|
||
const isStreamResponse =
|
||
contentType.includes('text/event-stream') || contentType.includes('stream')
|
||
|
||
if (!response.ok) {
|
||
// 非流式错误响应,使用 text() 读取错误信息
|
||
const errorText = await response.text()
|
||
console.error('请求错误:', errorText)
|
||
if (!isPaused.value) {
|
||
aiMessage.text = '发送失败,请重试'
|
||
}
|
||
aiMessage.streaming = false
|
||
aiMessage.loading = false
|
||
isGenerating.value = false
|
||
return
|
||
}
|
||
|
||
// 不是流式响应,使用 text()读取错误信息
|
||
if (!isStreamResponse) {
|
||
const text = await response.text()
|
||
try {
|
||
const errorData = JSON.parse(text)
|
||
console.error('非流式响应:', errorData)
|
||
} catch (e) {
|
||
console.error('非流式响应文本:', text)
|
||
}
|
||
if (!isPaused.value) {
|
||
aiMessage.text = '发送失败,请重试'
|
||
}
|
||
aiMessage.streaming = false
|
||
aiMessage.loading = false
|
||
isGenerating.value = false
|
||
return
|
||
}
|
||
|
||
// 流式响应处理
|
||
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)
|
||
aiMessage.streaming = false
|
||
aiMessage.loading = false
|
||
isGenerating.value = false
|
||
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:
|
||
|
||
let isNodeIdEvent = false
|
||
if (event.startsWith('event:')) {
|
||
isNodeIdEvent = true
|
||
// continue
|
||
}
|
||
console.log('event', event)
|
||
if (event.includes('error')) {
|
||
aiMessage.text = '出现错误,请重试'
|
||
aiMessage.streaming = false
|
||
aiMessage.loading = false
|
||
isGenerating.value = false
|
||
flag = false
|
||
break
|
||
}
|
||
|
||
const dataLines = event
|
||
.split(/\n/)
|
||
.filter((line) => line.startsWith('data:'))
|
||
.map((line) => line.replace(/^data:\s*/, '').trim())
|
||
// console.log('dataLInes', dataLines)
|
||
if (isNodeIdEvent) {
|
||
params.versionID = dataLines[0]
|
||
projectStore.setProject({ nodeId: dataLines[0] })
|
||
}
|
||
|
||
if (dataLines.length === 0) continue
|
||
const jsonText = dataLines.join('\n')
|
||
|
||
try {
|
||
const jsonData = JSON.parse(jsonText)
|
||
console.log('jsonData', jsonData)
|
||
|
||
// 赋值 project_id 和 version_id
|
||
// if (jsonData.project_id) params.projectID = jsonData.project_id
|
||
// if (jsonData.version_id) params.versionID = jsonData.version_id
|
||
if (jsonData.image_url) {
|
||
sketchList.value.push(jsonData.image_url)
|
||
}
|
||
if (
|
||
jsonData.content &&
|
||
jsonData.content.length > 0 &&
|
||
jsonData.type !== 'end'
|
||
) {
|
||
contentBody += jsonData.content
|
||
aiMessage.text = contentBody
|
||
aiMessage.loading = false
|
||
}
|
||
if (jsonData.type === 'end') {
|
||
aiMessage.streaming = false
|
||
aiMessage.loading = false
|
||
isGenerating.value = false
|
||
flag = false
|
||
break
|
||
}
|
||
} catch (e) {
|
||
// 检查是否为纯文本 [DONE]
|
||
if (jsonText.trim() === '[DONE]') {
|
||
console.log('结束-----------------------')
|
||
aiMessage.streaming = false
|
||
aiMessage.loading = false
|
||
isGenerating.value = false
|
||
flag = false
|
||
break
|
||
}
|
||
// JSON 不完整:保留到下一次循环
|
||
if (!jsonText.trim().endsWith('}')) {
|
||
buffer = 'data: ' + jsonText // 重新放回缓存
|
||
continue
|
||
} else {
|
||
console.warn('⚠️ JSON 格式错误,跳过:', jsonText)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('流式传输错误:', error)
|
||
if (!isPaused.value) {
|
||
aiMessage.text = '发送失败,请重试'
|
||
}
|
||
aiMessage.streaming = false
|
||
aiMessage.loading = false
|
||
isGenerating.value = false
|
||
} finally {
|
||
reader.releaseLock()
|
||
}
|
||
} catch (error) {
|
||
console.error('fetch请求失败:', error)
|
||
if (!isPaused.value) {
|
||
aiMessage.text = '发送失败,请重试'
|
||
}
|
||
aiMessage.streaming = false
|
||
aiMessage.loading = false
|
||
isGenerating.value = false
|
||
}
|
||
}
|
||
|
||
const handlePause = () => {
|
||
isPaused.value = true
|
||
isGenerating.value = false
|
||
abort?.abort()
|
||
}
|
||
|
||
const handleRegenerate = async (aiMessage: any) => {
|
||
// 找到当前 AI 消息在列表中的索引
|
||
const aiIndex = messageList.value.findIndex((msg) => msg.id === aiMessage.id)
|
||
if (aiIndex === -1) return
|
||
|
||
// 找到对应的用户消息(AI 消息前面的最近一条用户消息)
|
||
let userMessage = null
|
||
for (let i = aiIndex - 1; i >= 0; i--) {
|
||
if (messageList.value[i].isUser) {
|
||
userMessage = messageList.value[i]
|
||
break
|
||
}
|
||
}
|
||
if (!userMessage) return
|
||
|
||
// 删除当前的 AI 回复消息
|
||
messageList.value.splice(aiIndex, 1)
|
||
|
||
// 重新调用 API(跳过用户消息添加,因为用户消息已存在)
|
||
await handleSendMessage(
|
||
{
|
||
text: userMessage.text,
|
||
images: userMessage.images || []
|
||
},
|
||
true
|
||
)
|
||
}
|
||
|
||
const setChatInfo = (info) => {
|
||
// 先清空列表
|
||
const data = info.conversation
|
||
const project = info.project
|
||
messageList.value = []
|
||
params.versionID = ''
|
||
sketchList.value = []
|
||
params.configParams.type = project.type
|
||
params.configParams.region = project.area
|
||
params.configParams.style = project.style
|
||
params.configParams.temperature = project.temperature
|
||
// 如果没有数据,直接返回
|
||
if (!data) return
|
||
|
||
const { ancestors, current } = data
|
||
const imgList = []
|
||
const ancestorsList = []
|
||
if (ancestors) {
|
||
ancestors.forEach((item) => {
|
||
const list =
|
||
item.dialogue?.map((el, index) => {
|
||
if (el.image_url) {
|
||
imgList.push(el.image_url)
|
||
}
|
||
return {
|
||
...el,
|
||
text: el.content,
|
||
isUser: el.role === 'user',
|
||
id: index + 1
|
||
}
|
||
}) || []
|
||
ancestorsList.push(...list)
|
||
})
|
||
}
|
||
const currentList =
|
||
current?.dialogue?.map((item, index) => {
|
||
if (item.image_url) {
|
||
imgList.push(item.image_url)
|
||
}
|
||
return {
|
||
...item,
|
||
text: item.content,
|
||
isUser: item.role === 'user',
|
||
id: index + 1 + ancestorsList.length
|
||
}
|
||
}) || []
|
||
|
||
// 延迟设置新数据,确保 UI 有时间响应清空操作
|
||
nextTick(() => {
|
||
messageList.value = [...ancestorsList, ...currentList]
|
||
params.versionID = current?.id
|
||
sketchList.value = imgList
|
||
})
|
||
}
|
||
|
||
defineExpose({
|
||
setChatInfo
|
||
})
|
||
</script>
|
||
|
||
<style lang="less" scoped>
|
||
.c-svg {
|
||
width: initial;
|
||
}
|
||
.agent-container {
|
||
// width: 39%;
|
||
// width: 63.4rem;
|
||
flex: 1;
|
||
margin-right: 2.7rem;
|
||
background-color: #fff;
|
||
border-radius: 2rem;
|
||
box-shadow: 0px 15px 21px 0px #0000000d;
|
||
|
||
.agent-header {
|
||
height: 7.4rem;
|
||
border-bottom: 0.1rem solid #c9c9c9;
|
||
font-family: 'GeneralMedium';
|
||
padding: 1.4rem 3.4rem 1.4rem 3.1rem;
|
||
|
||
.agent-title {
|
||
font-size: 1.8rem;
|
||
margin-bottom: 0.6rem;
|
||
}
|
||
|
||
.agent-name {
|
||
font-size: 1.4rem;
|
||
}
|
||
}
|
||
.agent-body {
|
||
padding: 3.2rem;
|
||
overflow: hidden;
|
||
row-gap: 2.4rem;
|
||
.assist-input-wrapper {
|
||
width: 100%;
|
||
height: 14.4rem;
|
||
min-height: 14.4rem !important;
|
||
max-height: 14.4rem !important;
|
||
}
|
||
}
|
||
.input-wrapper {
|
||
height: 14.4rem;
|
||
padding: 0 2rem 3rem;
|
||
}
|
||
}
|
||
</style>
|