Files
FiDA_Front/src/views/home/agent/components/Agent.vue

529 lines
13 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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,
tempImages: initialData.tempImages
})
// 更新 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 })
console.log('收到chunk',new Date().getTime());
// 优先按空行拆分事件块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
)
}
// 处理对话列表,将连续的 assistant 消息合并为一条
const processDialogue = (dialogue, startIndex, existingImgList) => {
if (!dialogue || dialogue.length === 0) return []
const result = []
let i = startIndex
while (i < dialogue.length) {
const item = dialogue[i]
if (item.image_url) {
existingImgList.push(item.image_url)
}
if (item.role === 'user') {
// user 角色直接添加
result.push({
...item,
text: item.content,
isUser: true,
id: result.length + 1
})
i++
} else if (item.role === 'assistant') {
// assistant 角色,拼接直到下一个 user
let combinedContent = item.content || ''
// 继续往后找连续的 assistant 消息
let j = i + 1
while (j < dialogue.length && dialogue[j].role === 'assistant') {
if (dialogue[j].image_url) {
existingImgList.push(dialogue[j].image_url)
}
combinedContent += dialogue[j].content || ''
j++
}
result.push({
...item,
content: combinedContent,
text: combinedContent,
isUser: false,
id: result.length + 1
})
i = j
} else {
// 其他角色直接添加
result.push({
...item,
text: item.content,
isUser: item.role === 'user',
id: result.length + 1
})
i++
}
}
return result
}
const setChatInfo = (info) => {
const initialData = agentStore.getInitialProjectData
if (isGenerating.value || initialData) return
const data = info.conversation
let project = info.project
if (info.id) {
project = info
}
params.versionID = ''
sketchList.value = []
if (project) {
params.configParams.type = project.type
params.configParams.region = project.area
params.configParams.style = project.style
params.configParams.temperature = project.temperature
}
// 如果没有数据,直接返回
if (!data) {
messageList.value = []
return
}
const { ancestors, current } = data
const imgList = []
const ancestorsList = []
let ancestorsIdCounter = 1
if (ancestors) {
ancestors.forEach((item) => {
const list = processDialogue(item.dialogue, 0, imgList)
// 重新设置 id
list.forEach((el) => {
el.id = ancestorsIdCounter++
})
ancestorsList.push(...list)
})
}
const currentList = processDialogue(current?.dialogue, 0, imgList)
// 重新设置 id
currentList.forEach((el, index) => {
el.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;
width: 634px;
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;
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>