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

733 lines
19 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" />
<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,
onActivated
} from 'vue'
import List from './List.vue'
import Input from '../../components/Input.vue'
import { chatUrl } from '@/api/agent'
import type { AgentParamsType } from '@/api/agent'
import { useUserInfoStore, useProjectStore, useAgentStore } from '@/stores'
import MyEvent from '@/utils/myEvent'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
const { t } = useI18n()
const route = useRoute()
const userStore = useUserInfoStore()
const agentStore = useAgentStore()
const projectStore = useProjectStore()
const reportsContent = ref('')
const isGeneratingReport = ref(false)
const emits = defineEmits(['update:sketchList', 'setTitle'])
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: route.params.id as string,
message: '',
token: userStore.state.token,
versionID: '',
needSuggestion: false,
useReport: false,
configParams: {
type: '',
region: '',
style: ''
},
imageUrlList: [],
quotaUrl: []
})
const sketchList = ref([])
watch(
sketchList,
(newVal) => {
emits('update:sketchList', newVal)
},
{ deep: true }
)
const handleReset = () => {
messageList.value = []
sketchList.value = []
params.versionID = ''
params.imageUrlList = []
params.quotaUrl = []
params.needSuggestion = false
params.useReport = false
params.configParams = {
type: '',
region: '',
style: ''
}
isGenerating.value = false
}
// 每次请求时创建新的 AbortController
let abort: AbortController
const createAbortController = () => {
// 如果已有未完成的中止,先中止它
if (abort) {
abort.abort()
}
abort = new AbortController()
return abort
}
onUnmounted(() => {
// abort?.abort()
MyEvent.remove('resetAgent', handleReset)
})
onMounted(() => {
MyEvent.add('resetAgent', handleReset)
// 检查 store 中是否有初始项目数据
const initialData = agentStore.getInitialProjectData
if (initialData) {
// 等待页面渲染完成后自动发送初始消息
params.configParams = {
type: initialData.type,
region: initialData.area,
style: initialData.style,
temperature: 0.7
}
params.needSuggestion = initialData.needSuggestion || false
params.useReport = initialData.useReport
handleSendMessage({
text: initialData.text,
images: initialData.images,
useReport: initialData.useReport,
tempImages: initialData.tempImages,
quoteList: initialData.quoteList
})
// 更新 configParams
// 清空初始数据
agentStore.clearInitialProjectData()
}
})
// 用于存储不在页面显示但是仍进行中的对话信息
const newQueue = ref<{
nodeId?: string
name?: string
}>({})
onActivated(() => {
if (newQueue.value.nodeId) {
projectStore.setProject({ nodeId: newQueue.value.nodeId })
}
if (newQueue.value.name) {
MyEvent.emit('newTitle', {
title: newQueue.value.name,
id: params.projectID
})
}
if (newQueue.value.newSketch) {
mergeUniqueKeys(sketchList.value, newQueue.value.newSketch)
}
newQueue.value = {}
})
const handleSendMessage = async (
message: {
text: string
images: Array<{ url: string; name: string }>
tempImages: any[]
useReport: boolean
quoteList: Array<string>
},
skipUserMessage = false
) => {
isPaused.value = false
isGenerating.value = true
params.message = message.text
if (message.hasOwnProperty('useReport')) {
params.useReport = message.useReport
}
params.imageUrlList = message.images || []
params.quotaUrl = message.quoteList || []
// 如果不是重新生成模式,则添加用户消息到列表
if (!skipUserMessage) {
messageList.value.push({
id: messageList.value.length + 1,
text: message.text,
isUser: true,
imageUrls: message.tempImages.concat(message.quoteList)
})
}
// Add AI loading message
const aiMessage = reactive({
id: messageList.value.length + 1,
text: '',
isUser: false,
sessionId: route.params.id as string,
loading: true,
thinking: false,
thinkingText: '',
streaming: true
})
messageList.value.push(aiMessage)
// 创建新的 AbortController
const abortController = createAbortController()
// console.log('token---', params.token, '参数---', params)
params.projectID = route.params.id as string
try {
const urlParams = new URLSearchParams<AgentParamsType>({
...params,
configParams: JSON.stringify(params.configParams)
})
const BASEURL = import.meta.env.VITE_APP_URL
// console.log('params', params)
// debugger
const response = await fetch(`${BASEURL}${chatUrl}?${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()
let previousEventName = '' // 记录上一个事件名称
let hasReportStarted = false // 标记 report 是否已经开始
let hasSketchEvent = false
let hasReportEvent = false
try {
let flag = true
while (flag) {
const { done, value } = await reader.read()
if (done) {
if (hasSketchEvent) {
aiMessage.text += `<slot slot-name="sketch"></slot>`
}
if (hasReportEvent) {
aiMessage.text += `<slot slot-name="card" title="Report" content="Report"></slot>`
}
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
const eventName = event
.split(/\n/)
.find((line) => line.startsWith('event:'))
?.replace(/^event:\s*/, '')
?.trim()
if (!hasReportStarted && eventName === 'report') {
isGeneratingReport.value = true
hasReportEvent = true
// contentBody += `<slot slot-name="card" title="Report" content="Report"></slot>`
hasReportStarted = true
ElMessage.success(t('agent.generatingReport'))
}
if (
previousEventName === 'report' &&
eventName !== 'report' &&
reportsContent.value
) {
isGeneratingReport.value = false
sessionStorage.setItem(
'reportsContent_' + params.projectID,
reportsContent.value
)
}
previousEventName = eventName
// console.log('eventName:', eventName, 'event:', event)
// 根据事件名称精确判断,而不是用 includes
if (eventName === 'error') {
aiMessage.text = '出现错误,请重试'
aiMessage.streaming = false
aiMessage.loading = false
isGenerating.value = false
flag = false
break
}
if (eventName === 'todo') {
continue
}
let isNodeIdEvent = eventName === 'nodeId'
let hasSketch = eventName === 'sketchIDAndUrl'
const dataLines = event
.split(/\n/)
.filter((line) => line.startsWith('data:'))
.map((line) => line.replace(/^data:\s*/, ''))
.filter((content) => content.startsWith('{') || content.startsWith('['))
// console.log('dataLInes', dataLines)
if (isNodeIdEvent) {
const versionID = event
.split(/\n/)
.filter((line) => line.startsWith('data:'))
.map((line) => line.replace(/^data:\s*/, ''))[0]
params.versionID = versionID
if (aiMessage.sessionId === projectStore.state.id) {
projectStore.setProject({ nodeId: versionID })
} else {
newQueue.value.nodeId = versionID
}
}
if (eventName === 'tool') {
MyEvent.emit('loading-sketch', sketchList.value.length)
}
if (dataLines.length === 0) continue
const jsonText = dataLines.join('\n')
try {
const jsonData = JSON.parse(jsonText)
// console.log('jsonData', jsonData)
if (jsonData.webAddress) {
aiMessage.webAddress = JSON.parse(jsonData.webAddress)
contentBody += `<slot slot-name="url"></slot>`
}
if (jsonData.title) {
if (aiMessage.sessionId === projectStore.state.id) {
emits('setTitle', jsonData.title)
} else {
newQueue.value.name = jsonData.title
}
MyEvent.emit('newTitle', {
title: jsonData.title,
id: params.projectID
})
}
if (hasSketch) {
hasSketchEvent = true
let tempArr = []
if (params.projectID === projectStore.state.id) {
mergeUniqueKeys(sketchList.value, jsonData)
MyEvent.emit('OpenSketch', params.projectID)
} else {
mergeUniqueKeys(tempArr, jsonData)
newQueue.value.newSketch = tempArr
}
}
if (eventName === 'reportName' || eventName === 'reportTitle') {
aiMessage.reportName = jsonData.reportName || jsonData.reportTitle
}
if (eventName === 'report') {
reportsContent.value += jsonData.report
} else {
if (jsonData.reasoning) {
aiMessage.thinking = true
aiMessage.loading = false
aiMessage.thinkingText += jsonData.reasoning
} else {
aiMessage.thinking = false
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.text = contentBody
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)
}
}
// 更新上一个事件名称
previousEventName = eventName
}
}
} 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()
MyEvent.emit('stopChat')
}
// 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, sessionId) => {
if (!dialogue || dialogue.length === 0) return []
const result = []
let i = startIndex
while (i < dialogue.length) {
const item = dialogue[i]
if (item.role === 'user') {
result.push({
...item,
text: item.content,
isUser: true,
id: result.length + 1,
sessionId: sessionId
})
i++
} else if (item.role === 'assistant') {
let combinedContent = item.content || ''
let combinedThinkingText = item.reasoning || ''
let combinedImageUrl = item.image_url || null
let reportName = item.reportName || null
let webAddress = item.webAddress || null
let combinedReport = item.report || ''
let hasReportSlot = !!item.report
let hasUrlSlot = !!item.webAddress
let j = i + 1
while (j < dialogue.length && dialogue[j].role === 'assistant') {
const next = dialogue[j]
combinedContent += next.content || ''
combinedThinkingText += next.reasoning || ''
combinedReport += next.report || ''
if (next.image_url) combinedImageUrl = next.image_url
if (next.reportName) reportName = next.reportName
if (next.report) hasReportSlot = true
if (next.webAddress) {
webAddress = next.webAddress
hasUrlSlot = true
}
j++
}
if (hasUrlSlot) {
combinedContent += `<slot slot-name="url"></slot>`
}
if (hasReportSlot) {
combinedContent += `<slot slot-name="card" title="Report" content="Report"></slot>`
}
result.push({
...item,
reportName,
report: combinedReport,
content: combinedContent,
thinkingText: combinedThinkingText,
text: combinedContent,
image_url: combinedImageUrl,
webAddress: !!webAddress ? JSON.parse(webAddress) : null,
isUser: false,
id: result.length + 1,
sessionId: sessionId
})
i = j
} else {
result.push({
...item,
text: item.content,
isUser: item.role === 'user',
id: result.length + 1,
sessionId: sessionId
})
i++
}
}
return result
}
const processSession = (session, imgList, ancestorsList, idCounterRef) => {
if (!session) return
if (session.sketchIDAndUrl) {
imgList.push(session.sketchIDAndUrl)
}
const list = processDialogue(session.dialogue, 0, imgList, session.id)
list.forEach((el) => {
el.id = idCounterRef.value++
})
ancestorsList.push(...list)
}
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 = []
const idCounterRef = { value: 1 }
ancestors?.forEach((item) => {
processSession(item, imgList, ancestorsList, idCounterRef)
})
processSession(current, imgList, ancestorsList, idCounterRef)
nextTick(() => {
ancestorsList.forEach((item) => {
if (item.image_url && item.role !== 'user') {
item.text += `<slot slot-name="sketch"></slot>`
}
})
// console.log('ancestorslist', ancestorsList)
messageList.value = [...ancestorsList]
params.versionID = current?.id
sketchList.value = imgList
})
}
const mergeUniqueKeys = (targetArr, newData) => {
// 提取现有数组中所有的 key存入 Set 以实现 O(1) 查询
const existingKeys = new Set(targetArr.flatMap((item) => Object.keys(item)))
Object.entries(newData).forEach(([key, value]) => {
if (!existingKeys.has(key)) {
targetArr.push({ [key]: value })
existingKeys.add(key) // 防止 newData 内部有重复 key 时重复插入
}
})
}
defineExpose({
setChatInfo
})
</script>
<style lang="less" scoped>
.c-svg {
width: initial;
}
.agent-container {
// width: 39%;
// width: 63.4rem;
// flex: 1;
width: 67.4rem;
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: 18.1rem !important;
}
}
.input-wrapper {
height: 14.4rem;
padding: 0 2rem 3rem;
}
}
</style>